Skip to content

SQL Lab: joins

Exercise sql-lab-joins-annex.pdf · PDF

Generation 5 has 156 Pokémon. You counted them yourself in the last lesson. Now: which region is generation 5? Which game introduced it? In which year?

A fan will answer without blinking: Unova, Black and White, 2010. The table pokemon cannot. Its cards only carry a number, 5. What that number means is written somewhere else, in another table. Real databases are full of this: one table says "customer 4412 bought something", another says who 4412 is. This lesson is about reading two tables as one — and about the rows that quietly disappear when you do.

What you will learn

  • Load a second table, generations, and name the column that links it to pokemon: the key
  • Write an INNER JOIN with ON and table aliases to read columns from two tables at once
  • Predict how many rows an INNER JOIN and a LEFT JOIN return, and explain the difference
  • Defuse the trap of the day: a join can lose rows, without any error
  • Find the rows an INNER JOIN drops, with LEFT JOIN ... WHERE ... IS NULL
  • Combine a join with GROUP BY to get one figure per region

A second box: generations

We keep the shoebox from the last two lessons: pokemon is a box of 949 trading cards. Today a second, tiny box appears on the table: eight region cards.

Download generations.csv from the Resources page and load it the same way you loaded pokemon:

Step Do this You should see
1 In Superset: + (top right) → Data → Upload CSV to database The upload form
2 CSV file: generations.csv. Database: Dades classe. Schema: public (never leave it empty). Table name: generations. Delimiter: , The form filled in
3 Press Upload A confirmation message
4 In SQL → SQL Lab, schema public, run SELECT * FROM generations; 8 rows, 4 columns
SELECT * FROM generations ORDER BY generation_id;
generation_id region first_game release_year
1 Kanto Red and Green 1996
2 Johto Gold and Silver 1999
3 Hoenn Ruby and Sapphire 2002
4 Sinnoh Diamond and Pearl 2006
5 Unova Black and White 2010
6 Kalos X and Y 2013
7 Alola Sun and Moon 2016
8 Galar Sword and Shield 2019

There is the answer to the opening question, in row 5. This kind of small table that explains a code is called a lookup table.

If your table has 16 rows

You uploaded the file twice and the second upload was added to the first. Upload again and set If table already exists to Replace. Check for 8 rows before you go on: with 16, every count on this page doubles.

The key: the line printed on both cards

A key is a column that two tables share, and that tells you which row of one table belongs to which row of the other.

Look at both boxes. Which line is printed on the cards of both? generation_id. It is the only thing that connects a Pokémon card to its region card.

flowchart LR
    subgraph P ["<b>pokemon</b> · 949 cards"]
        direction TB
        P1["bulbasaur<br>generation_id = 1"]
        P2["pikachu<br>generation_id = 1"]
        P3["lucario<br>generation_id = 4"]
        P4["charizard-mega-x<br>generation_id = blank"]
    end
    subgraph G ["<b>generations</b> · 8 cards"]
        direction TB
        G1["1 · Kanto · 1996"]
        G4["4 · Sinnoh · 2006"]
        G8["8 · Galar · 2019"]
    end
    P1 -- "1 = 1" --> G1
    P2 -- "1 = 1" --> G1
    P3 -- "4 = 4" --> G4

Three things to notice in the drawing:

  • One region card, many Pokémon cards. Kanto alone is linked to 151 Pokémon.
  • charizard-mega-x has a blank generation_id. No line leaves it.
  • Galar has no line arriving. The Pokémon file stops at generation 7.

Hold on to the last two. They are the plot of this lesson.

INNER JOIN: clip the cards together

A join combines the rows of two tables into one result, matching them by a condition. An INNER JOIN keeps only the rows that found a partner.

Picture a paper clip. A join clips each Pokémon card to the region card with the same generation_id. The result is one wide card with the lines of both.

SELECT p.pokemon, p.type_1, g.region, g.release_year
FROM pokemon AS p
INNER JOIN generations AS g ON p.generation_id = g.generation_id
ORDER BY p.id;
pokemon type_1 region release_year
bulbasaur grass Kanto 1996
ivysaur grass Kanto 1996
venusaur grass Kanto 1996
charmander fire Kanto 1996
... ... ... ...

Two columns from one box, two from the other, in the same row. Three pieces of new grammar:

Piece What it does
FROM pokemon AS p p is a table alias: a short name for the table, valid in this query only. g is the alias of generations.
p.pokemon, g.region "The line pokemon of the card from box p", "the line region of the card from box g".
ON p.generation_id = g.generation_id The rule for clipping: which line must be equal on both cards.

The error everybody meets: ambiguous

Write ON generation_id = generation_id, without the prefixes, and PostgreSQL answers column reference "generation_id" is ambiguous. Both boxes have that line: which one do you mean? Whenever a column name exists in both tables, say whose it is: p.generation_id or g.generation_id.

The rows that vanish

pokemon has 949 rows. Before you run the next query, guess: how many rows does the join have?

SELECT COUNT(*)
FROM pokemon AS p
INNER JOIN generations AS g ON p.generation_id = g.generation_id;

802. Not 949. No error, no warning. 147 cards are gone.

You have met them before. In the last lesson, grouping by generation_id produced a pile with a blank key and exactly 147 cards. A blank is never equal to anything — remember = NULL from SQL Lab: filters. So those 147 cards find no partner, and an INNER JOIN leaves a card with no partner on the table, silently.

Careful: a join does not only add columns

It is natural to think "a join glues extra columns to my table, so the rows stay the same". Wrong. An INNER JOIN keeps a row only if it finds a partner. The query still runs and the result still looks fine. So make it a reflex: after every join, count the rows and compare with the count before.

LEFT JOIN: keep every card of the left box

A LEFT JOIN keeps every row of the left table — the one written first, after FROM. A row with no partner gets NULL in every column of the other table.

Change one word, INNER to LEFT, and count again:

SELECT COUNT(*)
FROM pokemon AS p
LEFT JOIN generations AS g ON p.generation_id = g.generation_id;

949. Everybody is back.

Join With the two boxes Rows
INNER JOIN Keep only the clipped pairs. A card with no partner is left out. 802
LEFT JOIN Keep every card of the left box. No partner? It gets a blank region card. 949

One Pokémon shows the whole difference. Search charizard% with a LEFT JOIN:

SELECT p.pokemon, g.region
FROM pokemon AS p
LEFT JOIN generations AS g ON p.generation_id = g.generation_id
WHERE p.pokemon LIKE 'charizard%'
ORDER BY p.id;
pokemon region
charizard Kanto
charizard-mega-x
charizard-mega-y

Three rows, two of them with a blank region. Run the same query with INNER JOIN and you get one row: plain charizard. The two Mega Charizards vanish.

Finding the vanished

That blank is useful. A LEFT JOIN followed by "where the other side is blank" lists exactly the rows that an INNER JOIN would drop:

SELECT p.pokemon, p.generation_id, g.region
FROM pokemon AS p
LEFT JOIN generations AS g ON p.generation_id = g.generation_id
WHERE g.region IS NULL
ORDER BY p.id;

147 rows. The first ones: deoxys-attack, deoxys-defense, deoxys-speed, wormadam-sandy, wormadam-trash, shaymin-sky... Read down the list and a pattern appears: they are all alternate forms of a Pokémon that already exists. 48 of them are Mega evolutions, 12 are costume Pikachus, others are Alola forms. In this file, alternate forms have no generation.

Which table is on the left?

"Left" just means written first. So you choose which box is kept whole. Turn the join around:

SELECT g.region, COUNT(p.id) AS n
FROM generations AS g
LEFT JOIN pokemon AS p ON p.generation_id = g.generation_id
GROUP BY g.region
ORDER BY n DESC;
region n
Unova 156
Kanto 151
Hoenn 135
Sinnoh 107
Johto 100
Alola 81
Kalos 72
Galar 0

Galar appears for the first time, with 0 Pokémon. It was the region card that no Pokémon card pointed at.

This query also shows that a join and GROUP BY work together: first the cards are clipped, then the wide cards are dealt into piles by region.

Careful: COUNT(*) would say Galar has 1

With COUNT(*) instead of COUNT(p.id), Galar shows 1, not 0. The LEFT JOIN gives Galar one wide card with blank Pokémon lines, and COUNT(*) counts cards. COUNT(p.id) counts only the cards where a Pokémon was really found. It is the COUNT(*) versus COUNT(column) difference from SQL Lab: groups, now with consequences.

Three counts, one table

Same two tables, same key, three different results:

Join Rows Who is missing
pokemon INNER JOIN generations 802 The 147 Pokémon with no generation and Galar
pokemon LEFT JOIN generations 949 Galar. Every Pokémon is there; 147 have a blank region
generations LEFT JOIN pokemon 803 The 147 Pokémon. Every region is there; Galar has blank Pokémon columns

And the choice changes answers, not only counts. Who is the strongest Pokémon, and from which region?

SELECT p.pokemon, p.attack, g.region
FROM pokemon AS p
INNER JOIN generations AS g ON p.generation_id = g.generation_id
ORDER BY p.attack DESC
LIMIT 3;
pokemon attack region
kartana 181 Alola
rampardos 165 Sinnoh
slaking 160 Hoenn

regigigas also has an attack of 160: a tie, so you may see it instead of slaking.

SELECT p.pokemon, p.attack, g.region
FROM pokemon AS p
LEFT JOIN generations AS g ON p.generation_id = g.generation_id
ORDER BY p.attack DESC
LIMIT 3;
pokemon attack region
mewtwo-mega-x 190
heracross-mega 185
kartana 181 Alola

Same data, same question, two answers. The Mega evolutions have no generation in this file, so the INNER JOIN threw away the strongest cards in the box. Neither join is "the correct one". You choose, and you write down what you chose.

A join only reads

After all this clipping, pokemon still has 949 rows and generations still has 8. A join is part of a SELECT: it builds a result on your screen and changes nothing in the tables.

Try it: the rows that vanish

In SQL Lab, with the aliases p and g and always ON p.generation_id = g.generation_id. For each join, write down how many rows come out.

N. Question
1 Region and first game of pikachu, lucario and greninja, in one query.
2 How many Pokémon per region, with INNER JOIN and GROUP BY g.region? How many rows?
3 The same with LEFT JOIN. How many rows now? What is the new one?
4 Average attack per region, strongest region first (rounded, INNER JOIN).
Solutions

1 — SELECT p.pokemon, g.region, g.first_game FROM pokemon AS p INNER JOIN generations AS g ON p.generation_id = g.generation_id WHERE p.pokemon IN ('pikachu', 'lucario', 'greninja'); → Kanto, Red and Green · Sinnoh, Diamond and Pearl · Kalos, X and Y.

2 — SELECT g.region, COUNT(*) AS n FROM pokemon AS p INNER JOIN generations AS g ON p.generation_id = g.generation_id GROUP BY g.region ORDER BY n DESC; → 7 rows: Unova 156, Kanto 151, Hoenn 135, Sinnoh 107, Johto 100, Alola 81, Kalos 72.

3 — Change INNER to LEFT → 8 rows: the same seven, plus a row with a blank region and 147.

4 — SELECT g.region, ROUND(AVG(p.attack)) AS avg_attack FROM pokemon AS p INNER JOIN generations AS g ON p.generation_id = g.generation_id GROUP BY g.region ORDER BY avg_attack DESC; → Alola 83, Unova 81, Sinnoh 80; Kanto, Hoenn and Kalos 73; Johto 68.

If a join returns 7592 rows, the ON condition is always true, or the tables were joined with a comma instead of a JOIN: every Pokémon got clipped to every region (949 × 8).

Key takeaways

  • A key is the column two tables share. Here it is generation_id: one region card, many Pokémon cards.
  • A join clips matching rows into one wide row. ON says which columns must be equal; table aliases (p, g) say whose column you mean.
  • An INNER JOIN keeps only clipped pairs: 802 rows. A blank key never matches, so 147 Pokémon vanish without an error.
  • A LEFT JOIN keeps every row of the table written first: 949 rows, with NULL where there is no partner.
  • LEFT JOIN ... WHERE g.region IS NULL lists the rows an INNER JOIN drops.
  • After every join, count the rows and compare with the count before. The join you choose changes the answer.

Quick check

1. What is the key that links pokemon and generations?
  • region, because it is the most readable column
  • generation_id, because it is the column both tables have
  • id, because every Pokémon has one
  • pokemon, because it is the name of the table

A key is the column shared by both tables. It tells you which region card belongs to which Pokémon card. region only exists in generations, and id only in pokemon.

2. pokemon has 949 rows. The INNER JOIN with generations returns 802. Why?
  • The join removes duplicated Pokémon
  • SQL Lab only shows the first 802 rows
  • 147 Pokémon have a blank generation_id, find no partner and are left out
  • generations only has room for 802 Pokémon

An INNER JOIN keeps only rows that find a partner. A blank (NULL) is never equal to anything, so those 147 alternate forms match no region. No error is shown: you only notice if you count.

3. In FROM pokemon AS p LEFT JOIN generations AS g ON ..., which rows are guaranteed to be in the result?
  • Every row of pokemon
  • Every row of generations
  • Every row of both tables
  • Only the rows that match in both tables

A LEFT JOIN keeps the left table whole, and the left table is the one written first, after FROM. Here that is pokemon: 949 rows, 147 of them with a blank region. Galar is still missing.

4. Which query lists the Pokémon that an INNER JOIN would drop?
  • ... INNER JOIN generations AS g ON ... WHERE g.region IS NULL
  • ... LEFT JOIN generations AS g ON ... WHERE g.region IS NULL
  • ... LEFT JOIN generations AS g ON ... WHERE g.region = NULL
  • ... INNER JOIN generations AS g ON ... WHERE p.generation_id = 0

The LEFT JOIN keeps the partnerless cards and fills their region with NULL; IS NULL then picks exactly those. With an INNER JOIN they are already gone, and = NULL is never true.

5. Why does PostgreSQL complain that the column reference generation_id is ambiguous?
  • The column has blanks in it
  • The two columns have different data types
  • Both tables have a column with that name, and the query does not say which one it means
  • The table generations was uploaded twice

When a column name exists in both tables, you must say whose it is with the table alias: p.generation_id or g.generation_id.

6. generations AS g LEFT JOIN pokemon AS p, grouped by region, shows Galar with 1 when you use COUNT(*) and with 0 when you use COUNT(p.id). Which is right, and why?
  • 1, because every region has at least one Pokémon
  • 1, because COUNT(*) is always the more exact one
  • 0, because COUNT(p.id) only counts rows where a Pokémon was really found
  • Neither: Galar should not appear at all

The LEFT JOIN gives Galar one row with blank Pokémon columns. COUNT(*) counts that row; COUNT(p.id) skips the blank. Galar has no Pokémon in this file, so 0 is the honest number.

7. A shop has customers(customer_id, name) and orders(order_id, customer_id). You want the customers who have never ordered. Pick the two things your query needs.
  • FROM customers AS c LEFT JOIN orders AS o ON c.customer_id = o.customer_id
  • WHERE o.order_id IS NULL
  • FROM customers AS c INNER JOIN orders AS o ON c.customer_id = o.customer_id
  • WHERE o.order_id = NULL

Same pattern as the 147: put the table you want to keep whole on the left (customers), then keep the rows where the other side is blank. An INNER JOIN would drop exactly the customers you are looking for.

You wrote some good queries today. Where will they be tomorrow? Next: Datasets and destination, where you save the question and store the answer.