Everything you've done in this module consists of matching rows horizontally: taking a row from orders, finding its partner in customers and gluing them side by side to get a wider row. JOINs add columns.
There's a second, completely different way of combining: stacking results vertically. Two independent queries are run, each with its own columns, and their rows are put one below the other. The set operators —UNION, INTERSECT and EXCEPT— add rows.
This lesson closes module 3. With it you'll have both ways of combining information in SQL and you'll know which to reach for in each case.
Contents
JOINversus set operators- The compatibility rules
UNIONandUNION ALL- The unified contact list
INTERSECT: what's in bothEXCEPT: what's in the first and not in the secondEXCEPTversus 03-03's anti-joinINTERSECT ALLandEXCEPT ALL- Precedence and parentheses
ORDER BYandLIMITover the combined result- Support by engine
- Common Mistakes and Tips
- Exercises
- Conclusion
JOIN versus set operators
JOIN versus set operatorsflowchart TB
subgraph J["JOIN — combines HORIZONTALLY"]
direction LR
J1["row from A<br/>(3 columns)"] --- J2["row from B<br/>(4 columns)"]
J2 --- J3["→ one row<br/>of 7 columns"]
end
subgraph U["UNION — combines VERTICALLY"]
direction TB
U1["rows from A<br/>(3 columns)"]
U2["rows from B<br/>(3 columns)"]
U1 --- U2
U2 --- U3["→ more rows,<br/>always 3 columns"]
end
JOIN |
Set operators | |
|---|---|---|
| What it does | Matches rows from two tables | Stacks the results of two queries |
| Effect on the result | More columns | More rows |
| Relationship between the tables | Needs a matching condition (ON) |
None: the queries are independent |
| Requirement | That a path exists between the tables | The same number of columns and compatible types |
| Example | "Each order with its customer's name" | "Every contact: customers, employees and suppliers" |
The key difference is the second half of the third row: set operators don't need the tables to be related. You can combine the results of two queries over tables that don't share a single foreign key, as long as their columns line up.
- The compatibility rules
For two queries to be combinable there are three rules, and all three are strict:
1. The same number of columns.
2. Compatible types, column by column, in the same order.
Column 1 of the first query is combined with column 1 of the second, 2 with 2, and so on. The names don't matter; the position does. PostgreSQL applies its implicit conversion rules: INTEGER and NUMERIC combine without any trouble, VARCHAR and TEXT too, but DATE and VARCHAR don't.
3. The result's column names come from the first query.
The result's column is called contact. The second query's alias is ignored entirely, and that's a classic source of confusion when reading other people's code.
Practical consequence: write the aliases in the first query and don't bother repeating them in the others (although doing so helps document what each column is).
The trick for missing columns
What happens if one table doesn't have a column the other one has? In GreenStore, customers and suppliers have email, but employees doesn't. The solution is to fill the gap with a literal:
The ::varchar is an explicit cast. Without it, PostgreSQL can sometimes infer the NULL's type from the other branch, but not always; writing it avoids the failed to determine data type of column error. Casts are studied in depth in lesson 06-04.
UNION and UNION ALL
UNION and UNION ALLUNION stacks the rows of two queries and removes duplicates. UNION ALL stacks them and removes nothing.
Let's see it with the cities where GreenStore has a presence:
| city |
|---|
| Alicante |
| Barcelona |
| Castellón |
| Lisbon |
| Lyon |
| Madrid |
| Paris |
| Porto |
| Seville |
| Valencia |
| Zaragoza |
11 rows. And the same query with UNION ALL returns 23 rows: the 15 cities from customers (with Valencia repeated four times and Barcelona twice) plus the 8 from employees (with Valencia seven times).
| Operator | Rows here | What it does | Cost |
|---|---|---|---|
UNION ALL |
23 | Concatenates, nothing more | Cheap: it just reads and emits |
UNION |
11 | Concatenates and deduplicates | Expensive: it needs to sort or build a hash table |
Why UNION ALL is usually what you want
UNION without ALL does extra work that is often not merely unnecessary but wrong:
- It's slower. Deduplicating requires sorting the whole result or maintaining a hash structure in memory. With millions of rows, the difference is enormous.
- It can delete legitimate rows. If two different customers had the same name and lived in the same city,
SELECT name, city FROM customers UNION ...would collapse them into one. You'd have lost a customer without noticing. - It compares the whole row. Two rows are duplicates only if all their columns match. Adding an
idcolumn to theSELECTmakesUNIONstop removing anything, and then you're only paying the cost.
Course rule: use
UNION ALLby default. Fall back onUNIONonly when removing duplicates is the query's explicit goal, as in the city listing above. It's the same philosophy asDISTINCTin 02-04: if you need it to "fix" a result, check first whether the query is properly framed.
This is also why MySQL's FULL OUTER JOIN emulation (03-05) uses UNION and not UNION ALL: there the two halves do produce the same matching rows, and they have to be removed.
- The unified contact list
A real case: the company wants a single address book with everyone and everything it deals with, flagging where each entry comes from. The three sources live in different tables with no relationship between them: it's the perfect scenario for UNION ALL.
SELECT 'customer' AS source,
c.name || ' ' || c.last_name AS name,
c.email,
c.city
FROM customers AS c
UNION ALL
SELECT 'employee',
e.name || ' ' || e.last_name,
NULL::varchar,
e.city
FROM employees AS e
UNION ALL
SELECT 'supplier',
s.name,
s.email,
NULL::varchar
FROM suppliers AS s
ORDER BY source, name;| source | name | city | |
|---|---|---|---|
| customer | Ana Belmonte Roca | ana.belmonte@example.com | Barcelona |
| customer | Camille Dubois | camille.dubois@example.fr | Lyon |
| customer | Carlos Ferrer Ibáñez | carlos.ferrer@example.com | Valencia |
| customer | Diego Ramos Herrera | diego.ramos@example.com | Seville |
| customer | Elena Navarro Puig | elena.navarro@example.com | Alicante |
| customer | Hugo Iglesias Pardo | hugo.iglesias@example.com | Zaragoza |
| customer | Inés Carrasco Vega | ines.carrasco@example.com | Valencia |
| customer | Javier Ortega Ruiz | javier.ortega@example.com | Madrid |
| customer | Julien Moreau | julien.moreau@example.fr | Paris |
| customer | Lucía Martínez Soler | lucia.martinez@example.com | Valencia |
| customer | Marta Sanchis Gil | marta.sanchis@example.com | Castellón |
| customer | Núria Bosch Ferrer | nuria.bosch@example.com | Barcelona |
| customer | Pau Llorens Vidal | pau.llorens@example.com | Valencia |
| customer | Sofia Moreira Costa | sofia.moreira@example.pt | Lisbon |
| customer | Tiago Almeida Nunes | tiago.almeida@example.pt | Porto |
| employee | Andrés Company Talens | (null) | Valencia |
| employee | Beatriz Nadal Ripoll | (null) | Valencia |
| employee | Daniel Vercher Lluch | (null) | Valencia |
| employee | Irene Salvador Mira | (null) | Valencia |
| employee | Laia Puig Sanchis | (null) | Castellón |
| employee | Marc Estévez Roig | (null) | Valencia |
| employee | Óscar Peris Blasco | (null) | Valencia |
| employee | Rosa Alcázar Vives | (null) | Valencia |
| supplier | BioSierra Ibérica | comercial@biosierra.es | (null) |
| supplier | EcoNordic Supplies | sales@econordic.de | (null) |
| supplier | Huerta del Turia | pedidos@huertadelturia.es | (null) |
| supplier | Maison Nature | contact@maisonnature.fr | (null) |
| supplier | Verde Atlántico | encomendas@verdeatlantico.pt | (null) |
28 rows = 15 customers + 8 employees + 5 suppliers.
Four design decisions in this query deserve comment:
| Decision | Why |
|---|---|
The literal column 'customer' |
Without it, the result would be a list of names with no way of knowing where each one comes from. A source column is essential in any UNION of heterogeneous sources |
UNION ALL and not UNION |
A customer and an employee could share a name; with UNION one of the two would disappear. Besides, the source column makes them distinct anyway, so UNION would only cost time |
NULL::varchar for the absent columns |
employees has no email and suppliers has no city. The gap is filled with a null of the right type |
ORDER BY at the very end, once |
It sorts the combined result, not each query separately. See section 10 |
Notice the alphabetical order as well: "Óscar" appears between "Marc" and "Rosa" because the database uses the en-US-x-icu collation you configured in 02-05. With the default C collation, "Óscar" would go at the end of the block, after "Rosa".
INTERSECT: what's in both
INTERSECT: what's in bothINTERSECT returns the rows that appear in both queries.
flowchart LR
subgraph R[" "]
direction LR
A(("only in A<br/>❌"))
I(("in A and in B<br/>✅"))
B(("only in B<br/>❌"))
end
style A fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
In which cities do we have both customers and employees?
| city |
|---|
| Castellón |
| Valencia |
Two cities. Valencia, where the head office is and where four customers live, and Castellón, where Laia Puig Sanchis works and Marta Sanchis Gil lives. It's a query with an immediate business reading: these are the cities where you could organise a hand delivery or an event with customers.
In which countries do we have both customers and suppliers?
| country |
|---|
| France |
| Portugal |
| Spain |
Three countries. Germany is left out because there's a supplier there (EcoNordic Supplies) but no customer.
Two properties of INTERSECT worth knowing:
- It removes duplicates by default, just like
UNION. If a city appeared five times incustomersand three inemployees, the result shows it once. - It's commutative:
A INTERSECT BandB INTERSECT Agive the same thing. It's the propertyEXCEPTlacks.
EXCEPT: what's in the first and not in the second
EXCEPT: what's in the first and not in the secondEXCEPT returns the rows of the first query that don't appear in the second. It's the set difference.
flowchart LR
subgraph R[" "]
direction LR
A(("only in A<br/>✅"))
I(("in A and in B<br/>❌"))
B(("only in B<br/>❌"))
end
style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style I fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
Which products have never been sold? The catalogue's identifiers, minus the identifiers that appear in the sales:
| id |
|---|
| 13 |
| 19 |
| 20 |
The same three as ever. The query reads almost like the question: "all the products, minus the ones that have been sold".
In which countries do we have a supplier but no customer?
| country |
|---|
| Germany |
One row. And now the key thing about EXCEPT: it isn't commutative. Turn it around:
Zero rows: there's no country with customers where we don't also have a supplier. The two queries answer different questions, and confusing them is the most frequent mistake with this operator.
| Query | Meaning | Result |
|---|---|---|
suppliers EXCEPT customers |
Countries where we buy but don't sell | Germany |
customers EXCEPT suppliers |
Countries where we sell but don't buy | (none) |
Dialect note: in Oracle this operator is called
MINUS, notEXCEPT. Since Oracle 21cEXCEPTis also accepted as a synonym, but you'll findMINUSin all the older code. The behaviour is identical.
EXCEPT versus 03-03's anti-join
EXCEPT versus 03-03's anti-joinYou now have two ways of answering "which products have never been sold?". Compare them:
-- Option A: anti-join (03-03)
SELECT p.id, p.name, p.price, p.stock, p.active
FROM products AS p
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
WHERE ol.id IS NULL
ORDER BY p.id;Both identify products 13, 19 and 20. But they aren't interchangeable:
| Aspect | Anti-join | EXCEPT |
|---|---|---|
| Columns it can return | All of products': name, price, stock, active... |
Only the ones being compared. If you add name to the first SELECT, you have to add something comparable to the second, and order_lines has no product name |
| Legibility of the intent | Requires understanding why the IS NULL works |
It reads like the sentence: "the products, minus the sold ones" |
| Duplicates | It keeps them | It always removes them |
| Typical use | Reports: you need the data of the rows found | Checks and reconciliations: the list of keys is enough |
| Performance | Excellent with an index on the FK | Requires deduplicating both sides; usually a bit more expensive |
When to choose each: if you need data from the resulting rows, use the anti-join. If you only need the list of identifiers —for a count, an integrity check, a reconciliation report—
EXCEPTis shorter and reads better.
In module 7 a third form will appear, NOT EXISTS with a correlated subquery, which combines the best of both: it returns the whole row and reads like the question. And a fourth, NOT IN, which looks the most natural and has treacherous behaviour around nulls. All four are compared in lesson 07-05.
INTERSECT ALL and EXCEPT ALL
INTERSECT ALL and EXCEPT ALLJust as UNION has its ALL variant, INTERSECT and EXCEPT have one too. Their semantics are multiset: instead of working with presence or absence, they count how many times each row appears.
| Operator | If a row appears m times in A and n times in B, it comes out... |
|---|---|
INTERSECT |
1 time (if m ≥ 1 and n ≥ 1) |
INTERSECT ALL |
min(m, n) times |
EXCEPT |
1 time (if m ≥ 1 and n = 0) |
EXCEPT ALL |
max(m − n, 0) times |
An example over the cities: Valencia appears 4 times in customers and 7 times in employees.
It returns Valencia 4 times —min(4, 7)— and Castellón 1 time —min(1, 1)—: 5 rows in total. With plain INTERSECT there'd be 2.
It returns Valencia 3 times —max(7 − 4, 0)— and nothing else: max(1 − 1, 0) = 0 for Castellón.
In practice they're used very little. Their ground is data-quality checks of the "has this migration duplicated rows?" kind, where the number of repetitions matters and not just their presence. It's useful to know they exist; it isn't common to write them.
Dialect note:
INTERSECT ALLandEXCEPT ALLare standard and present in PostgreSQL, but not in SQL Server or Oracle (whereMINUShas noALLvariant).
- Precedence and parentheses
When three or more queries are chained, the order of evaluation matters. The SQL standard establishes that:
INTERSECThas higher precedence thanUNIONandEXCEPT, which are evaluated left to right relative to each other.
That is, A UNION B INTERSECT C means A UNION (B INTERSECT C), just as 2 + 3 * 4 means 2 + (3 * 4).
Let's see it with a case that changes radically depending on the grouping. The query:
SELECT id FROM products
EXCEPT
SELECT product_id FROM order_lines
INTERSECT
SELECT product_id FROM reviews
ORDER BY id;PostgreSQL evaluates it as products EXCEPT (order_lines INTERSECT reviews):
order_lines INTERSECT reviews= the products that have been sold and have a review = 9 products (1, 2, 5, 6, 10, 12, 15, 16, 18).products EXCEPT those 9= 11 products: 3, 4, 7, 8, 9, 11, 13, 14, 17, 19 and 20.
| id |
|---|
| 3 |
| 4 |
| 7 |
| 8 |
| 9 |
| 11 |
| 13 |
| 14 |
| 17 |
| 19 |
| 20 |
They're exactly the 11 products with no review at all. Now let's force the other grouping with parentheses:
(SELECT id FROM products
EXCEPT
SELECT product_id FROM order_lines)
INTERSECT
SELECT product_id FROM reviews;Zero rows, because (products EXCEPT order_lines) is the three never-sold products (13, 19, 20) and none of them has a review. The same query, two groupings, 11 rows against 0.
Course rule: as soon as there are three or more queries chained with different operators, always use parentheses, even when they match the default precedence. They cost two characters and remove all ambiguity for whoever reads the code.
Important dialect note: SQLite doesn't implement this precedence. It evaluates compound operators strictly left to right, so this section's first query would return 0 rows in SQLite and 11 in PostgreSQL, MySQL, SQL Server and Oracle. It's a decisive argument in favour of explicit parentheses: with them, the query means the same thing on every engine.
ORDER BY and LIMIT over the combined result
ORDER BY and LIMIT over the combined resultORDER BY and LIMIT don't belong to either query: they're applied to the combined result and they go at the very end.
| city |
|---|
| Alicante |
| Barcelona |
| Castellón |
| Lisbon |
| Lyon |
The first five cities in alphabetical order from the already unified set. Important details:
| Detail | Explanation |
|---|---|
| The valid names are the first query's | If the first column is called contact, write ORDER BY contact, even if in the second query the column has another name |
| You can sort by position | ORDER BY 1 sorts by the first column. It's especially handy here, where names can be confusing (02-05) |
LIMIT trims the total, not each half |
LIMIT 5 over a UNION of two queries of 15 and 8 rows returns 5 rows in total |
| 02-06's rule still holds | Without an ORDER BY, the order of the combined result isn't guaranteed, not even "first A and then B" |
If you need to sort or limit one of the queries separately, it has to be wrapped in parentheses:
(SELECT city FROM customers ORDER BY city LIMIT 3)
UNION ALL
(SELECT city FROM employees ORDER BY city LIMIT 3);It's valid syntax in PostgreSQL, though uncommon: what you want is almost always to sort the total.
In the logical execution order, the step looks like this: each query is resolved completely (with its FROM, its JOINs, its WHERE and its SELECT), then the set operator is applied, and only then is it sorted and trimmed.
flowchart TD
A["query 1<br/>FROM → WHERE → SELECT"] --> C["set operator<br/>UNION / INTERSECT / EXCEPT"]
B["query 2<br/>FROM → WHERE → SELECT"] --> C
C --> D["ORDER BY<br/>over the combined result"]
D --> E["LIMIT / OFFSET"]
- Support by engine
| Engine | UNION / UNION ALL |
INTERSECT |
EXCEPT |
ALL variants |
|---|---|---|---|---|
| PostgreSQL | ✅ | ✅ | ✅ | ✅ INTERSECT ALL, EXCEPT ALL |
| MySQL / MariaDB | ✅ | ✅ since MySQL 8.0.31 (2022) | ✅ since 8.0.31 | ✅ since 8.0.31 |
| SQLite | ✅ | ✅ | ✅ | ❌ No ALL variants; and on top of that, left-to-right precedence |
| SQL Server | ✅ | ✅ | ✅ | ❌ |
| Oracle | ✅ | ✅ | ✅ as MINUS (and EXCEPT since 21c) |
❌ |
UNION is the only one of the three you can take for granted on any engine and any version. If you write portable SQL and need INTERSECT or EXCEPT on old MySQL, the alternative is a JOIN (for the intersection) or an anti-join (for the difference), which is exactly what everybody did before 2022.
Common Mistakes and Tips
- A different number of columns.
ERROR: each UNION query must have the same number of columns. Count the columns in each branch before running it. - Incompatible types in the same position. Columns are matched by position, not by name. A
DATEin position 2 of one branch and aVARCHARin position 2 of the other gives an error. - Expecting the second query's alias to be used. The result's names always come from the first one.
- Using
UNIONwhen you meantUNION ALL. It removes legitimately repeated rows and costs more. By default,UNION ALL. - Using
UNION ALLwhen there really was overlap. In theFULL OUTER JOINemulation (03-05) it would duplicate every matching row. - Reversing the order of an
EXCEPT. It isn't commutative:suppliers EXCEPT customersgives Germany; the other way round, zero rows. - Putting an
ORDER BYin an intermediate query.ERROR: syntax error at or near "UNION". It goes at the end, once, and affects the total. - Chaining three operators with no parentheses.
INTERSECTis evaluated beforeUNIONandEXCEPTin the standard, but not in SQLite. The same query returned 11 rows and 0 rows depending on the grouping. - Forgetting the source column in a
UNIONof different sources. Without it you can't tell whether "Laia Puig Sanchis" is an employee or a customer. - Tip: write the first branch, run it, and only then add the rest. Debugging a
UNIONof four queries written in one go is uncomfortable; type errors point at the union, not at the guilty column. - Tip: line the branches up visually. The same column order, the same indentation and the operator on a line of its own. A well-formatted
UNIONcan be reviewed at a glance. - Tip: use
ORDER BY 1, 2in set queries. Names can be misleading when the branches come from different tables; ordinals can't.
Exercises
Exercise 1
Build a directory of Spanish contacts: every person and entity in GreenStore whose country is Spain, with a column stating their type (customer, employee or supplier), their name and their city if there is one.
Bear in mind that employees has no country column —they all work in Spain— and that suppliers has no city. Sort by type and name.
Exercise 2
Answer with set operators:
- In which cities are there customers but no employees?
- In which cities are there employees but no customers?
- Explain why the two results are so different in size.
Exercise 3
A colleague wants the list of products that have been sold but have no review at all, and writes this:
SELECT product_id FROM order_lines
EXCEPT
SELECT product_id FROM reviews
UNION
SELECT id FROM products
ORDER BY 1;- How does PostgreSQL group this query and what does it actually return?
- Write the correct query for what he wanted.
- Write the same answer using a
JOINand an anti-join instead of set operators, returning the product's name too. Which of the two do you prefer, and why?
Solutions
Solution 1
SELECT 'customer' AS type,
c.name || ' ' || c.last_name AS name,
c.city
FROM customers AS c
WHERE c.country = 'Spain'
UNION ALL
SELECT 'employee',
e.name || ' ' || e.last_name,
e.city
FROM employees AS e
UNION ALL
SELECT 'supplier',
s.name,
NULL::varchar
FROM suppliers AS s
WHERE s.country = 'Spain'
ORDER BY type, name;| type | name | city |
|---|---|---|
| customer | Ana Belmonte Roca | Barcelona |
| customer | Carlos Ferrer Ibáñez | Valencia |
| customer | Diego Ramos Herrera | Seville |
| customer | Elena Navarro Puig | Alicante |
| customer | Hugo Iglesias Pardo | Zaragoza |
| customer | Inés Carrasco Vega | Valencia |
| customer | Javier Ortega Ruiz | Madrid |
| customer | Lucía Martínez Soler | Valencia |
| customer | Marta Sanchis Gil | Castellón |
| customer | Núria Bosch Ferrer | Barcelona |
| customer | Pau Llorens Vidal | Valencia |
| employee | Andrés Company Talens | Valencia |
| employee | Beatriz Nadal Ripoll | Valencia |
| employee | Daniel Vercher Lluch | Valencia |
| employee | Irene Salvador Mira | Valencia |
| employee | Laia Puig Sanchis | Castellón |
| employee | Marc Estévez Roig | Valencia |
| employee | Óscar Peris Blasco | Valencia |
| employee | Rosa Alcázar Vives | Valencia |
| supplier | BioSierra Ibérica | (null) |
| supplier | Huerta del Turia | (null) |
21 rows = 11 Spanish customers + 8 employees + 2 Spanish suppliers.
Three details of the reasoning:
employeescarries noWHEREbecause the table has nocountrycolumn: by design, the whole team works in Spain. It's an assumption of the model, and it's worth leaving it written in a comment so nobody takes it as an oversight.- The filters go in each branch's
WHERE, not at the end. AWHEREafter the lastUNION ALLwould belong only to the third query, not to the whole set. NULL::varcharkeeps the number of columns right in the suppliers branch.
Solution 2
1. Cities with customers but no employees:
| city |
|---|
| Alicante |
| Barcelona |
| Lisbon |
| Lyon |
| Madrid |
| Paris |
| Porto |
| Seville |
| Zaragoza |
9 cities.
2. Cities with employees but no customers:
None.
3. Why they're so different. Because the two sets have very different sizes and natures:
| Set | Distinct cities | Which ones |
|---|---|---|
| Customer cities | 11 | Valencia, Castellón, Madrid, Barcelona, Alicante, Seville, Zaragoza, Lisbon, Porto, Lyon, Paris |
| Employee cities | 2 | Valencia, Castellón |
The employees' cities are a subset of the customers': GreenStore only has offices in Valencia (head office) and Castellón (where Laia works), and there are customers in both. That's why employees EXCEPT customers is empty, while the reverse operation returns the other nine cities where there are customers and no physical presence.
A cross-check against section 5: 11 cities in total (UNION), 2 in common (INTERSECT), 9 customers-only (EXCEPT) and 0 employees-only. The numbers add up: 2 + 9 + 0 = 11.
Solution 3
1. How PostgreSQL groups it. There's no INTERSECT in the query, so the two remaining operators are evaluated left to right:
The first part gives the 8 sold products with no review; the second adds the catalogue's 20 products. The union of both sets is... the 20 products. The query returns the whole catalogue, which has absolutely nothing to do with the question. The UNION with products cancels all the EXCEPT's work.
2. The correct query. The third branch is entirely superfluous:
| product_id |
|---|
| 3 |
| 4 |
| 7 |
| 8 |
| 9 |
| 11 |
| 14 |
| 17 |
8 products sold at some point and never reviewed. Compare it with the 11 products with no review from section 9: the difference is the three that have never been sold (13, 19 and 20), which don't appear here because they aren't in order_lines.
3. With a JOIN and an anti-join:
SELECT DISTINCT p.id,
p.name AS product,
p.price
FROM products AS p
INNER JOIN order_lines AS ol ON ol.product_id = p.id
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE r.id IS NULL
ORDER BY p.id;| id | product | price |
|---|---|---|
| 3 | Raw orange blossom honey 500 g | 9.75 |
| 4 | Spelt pasta 500 g | 2.80 |
| 7 | Rosemary solid shampoo 80 g | 8.40 |
| 8 | Almond body oil 200 ml | 14.25 |
| 9 | Calendula lip balm 15 ml | 4.60 |
| 11 | Loofah scrubber (pack of 3) | 5.50 |
| 14 | Organic chamomile tea 20 bags | 3.25 |
| 17 | Cold-pressed orange juice 1 L | 5.40 |
The same 8 products, now with their name and price. The INNER JOIN with order_lines demands that they've been sold; the LEFT JOIN with reviews plus the WHERE r.id IS NULL demands that they have no review.
Which to prefer:
EXCEPT |
JOIN + anti-join |
|
|---|---|---|
| In favour | Short, reads like the question, impossible to get duplicates wrong | It returns the whole row: name, price, stock, whatever you need |
| Against | It only returns identifiers; for the report you have to go back to products |
It needs a DISTINCT —because the INNER JOIN multiplies by each sold line— and you have to reason out the IS NULL |
For this particular case, the JOIN is the better option, because a report with eight nameless numbers is no use to anybody. EXCEPT would be preferable if the query were an intermediate step of an automated check, where only the identifiers matter.
And notice the DISTINCT: it's exactly the symptom we talked about in 02-04 and 03-02. It shows up because the INNER JOIN with order_lines generates one row per sale, and the question is about products, not about sales. In module 7 you'll see that WHERE EXISTS (...) solves this without a DISTINCT and without multiplying rows.
Conclusion
You've closed the module's last piece:
- Set operators combine vertically: they stack rows from independent queries, whereas
JOINs combine horizontally by adding columns. They need no relationship between the tables. - The compatibility rules are three: the same number of columns, compatible types by position and result names taken from the first query. Gaps are filled with literals such as
NULL::varchar. UNION ALLis the default choice: it doesn't deduplicate, it's faster and it doesn't delete legitimately repeated rows.UNIONonly when removing duplicates is the goal, as in the list of 11 cities againstUNION ALL's 23.- You've built the unified contact list (28 rows from three unrelated tables) with a literal source column, which is what makes any
UNIONof heterogeneous sources readable. INTERSECTreturns what's on both sides and is commutative: two cities with customers and employees, three countries with customers and suppliers.EXCEPTreturns what's in the first and not in the second and isn't commutative: Germany one way round, zero rows the other. In Oracle it's calledMINUS.- You know when to choose
EXCEPTand when 03-03's anti-join:EXCEPTfor lists of identifiers, the anti-join when you need the rows' data. INTERSECTtakes precedence overUNIONandEXCEPT—except in SQLite, which evaluates left to right— and the same query can return 11 rows or 0 depending on the grouping. Always use parentheses when you chain three or more.ORDER BYandLIMITgo at the end and apply to the combined result, using the first query's column names or the ordinals.
And with that you close module 3
GreenStore's nine tables have stopped being nine islands. You know:
- That a
JOINis a filtered cartesian product, that its condition goes in theONand thatJOINs are resolved inside theFROM, before theWHERE. - How to use the
INNER JOINand predict which rows it loses, with the canonical four-table query —order_lines+orders+customers+products— that will stay with you all the way to the final project. - How to use the
LEFT JOINto keep what doesn't match, the anti-join pattern to find it, and why a condition in theWHEREover the right table silently degrades theLEFTinto anINNER. - That the
RIGHT JOINis its mirror and can always be rewritten as aLEFT, and that mixing them in a chain makes the query unreadable. - That the
FULL OUTER JOINis for reconciling two independent sources, and that referential integrity makes it unnecessary within a single schema. - How to walk reflexive relationships with a
SELF JOIN—the org chart and the referral network— and generate complete combinations with aCROSS JOIN. - And how to stack whole results with
UNION,INTERSECTandEXCEPT.
But notice what every query you've written in this module returns: detail rows. One row per order line, one per customer, one per pair of products. And the questions GreenStore's management really asks don't want detail, they want totals: how much each category bills, how many orders each customer has placed, what each product's average rating is, which sales rep closes the most sales, how many customers there are per country. To answer them you have to summarise many rows into one, and that's an operation you still don't know how to do.
In module 4, Advanced Filtering and Aggregation, the two missing halves arrive. First you'll sharpen the filtering: LIKE for searching by text patterns, IN and BETWEEN for ranges and lists, and the serious treatment of NULLs with IS NULL, IS NOT NULL and their three-valued logic —the one you've already seen peeking out in the ON, in the WHERE and in every LEFT JOIN of this module. And then comes aggregation: the COUNT, SUM, AVG, MIN and MAX functions, the GROUP BY clause that splits the result into groups and HAVING to filter those groups. There, this time with real consequences, the warning you've read three times in this module will come back: be careful about summing header values after joining with the detail.
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
