SQL Joins

Course setup (R): check versions and load packages
# Checks that R and each package are installed and new enough for this course.
# Stops with a list of everything that needs fixing, and how to fix it.
check_course_versions <- function(required, area) {
  problems <- character(0)
  to_install <- character(0)
  found <- character(0)
  for (name in names(required)) {
    need <- required[[name]]
    if (name == "R") {
      have <- getRversion()
      if (have < need) {
        problems <- c(problems, sprintf(
          "R is version %s; this course needs %s or newer. Install the current R from https://cran.r-project.org/ (see https://howisonlab.github.io/datawrangling/current/rtidyverse/rstudio_installation.html), then reinstall your packages.",
          have, need))
      }
      found <- c(found, paste("R", have))
    } else if (!nzchar(system.file(package = name))) {
      problems <- c(problems, sprintf("%s is not installed.", name))
      to_install <- c(to_install, name)
    } else {
      have <- packageVersion(name)
      if (have < need) {
        problems <- c(problems, sprintf(
          "%s is version %s; this course needs %s or newer.", name, have, need))
        to_install <- c(to_install, name)
      } else if (isNamespaceLoaded(name) && getNamespaceVersion(name) != have) {
        problems <- c(problems, sprintf(
          "%s %s is installed, but this R session is still using %s. Restart R.",
          name, have, getNamespaceVersion(name)))
      }
      found <- c(found, paste(name, have))
    }
  }
  if (length(problems) > 0) {
    fix <- "In RStudio choose Session > Restart R"
    if (length(to_install) > 0) {
      fix <- paste0(fix, ", then run this in the Console:\n  install.packages(c(",
                    paste0('"', to_install, '"', collapse = ", "), "))\n")
    }
    stop("Course setup (", area, ") found problems:\n  - ",
         paste(problems, collapse = "\n  - "), "\n\n",
         fix, "\nThen run the setup chunk(s) again.", call. = FALSE)
  }
  cat("Course setup OK (", area, "): ", paste(found, collapse = ", "), "\n", sep = "")
}

check_course_versions(c(
  R = "4.5.0",         # CRAN builds Mac/Windows packages for the current and previous R only
  tidyverse = "2.0.0",
  dplyr = "1.2.0",     # recode_values() and replace_values()
  tidyr = "1.3.0",     # separate_wider_delim()
  gt = "1.0.0",        # needed by gtExtras
  gtExtras = "0.6.1"   # works with ggplot2 4
), "R")

library(tidyverse)
library(gt)
library(gtExtras)
Course setup OK (R): R 4.6.1, tidyverse 2.0.0, dplyr 1.2.1, tidyr 1.3.2, gt 1.3.0, gtExtras 0.6.2
Course setup (SQL): check versions and connect to DuckDB
# needs check_course_versions() from the Course setup (R) chunk
check_course_versions(c(
  DBI = "1.0.0",
  duckdb = "1.5.5"     # duckdb(shared_home = FALSE)
), "SQL")

library(DBI)
library(duckdb)
options(duckdb.enable_rstudio_connection_pane = TRUE)

# a temporary in-memory database, nothing saved to a file
drv <- duckdb(shared_home = FALSE)
con <- dbConnect(drv)
Course setup OK (SQL): DBI 1.3.0, duckdb 1.5.6

The SQL setup chunk creates a temporary in-memory database. To keep your tables in a file instead, use drv <- duckdb(dbdir = "my_database.duckdb", shared_home = FALSE).

Joins

Joins are a key capability of SQL databases, and the underlying optimizations used for Joins are a core reason that SQL has remained so core to data work.

Often we want to combine data from different tables.

Take this example:

Show the code that creates the hobbies and majors tables
hobbies <- tribble(
  ~name, ~hobby,
  "Shuyen (both tables)", "dancing",
  "Brian (both tables)", "dancing",
  "Adnan (hobbies only)", "running"
)
dbWriteTable(con, "hobbies", hobbies, overwrite = TRUE)

majors <- tribble(
  ~name, ~major,
  "Shuyen (both tables)", "Art",
  "Brian (both tables)", "Theater",
  "Yungsheng (majors only)", "English"
)
dbWriteTable(con, "majors", majors, overwrite = TRUE)

hobbies

name hobby
Shuyen (both tables) dancing
Brian (both tables) dancing
Adnan (hobbies only) running

majors

name major
Shuyen (both tables) Art
Brian (both tables) Theater
Yungsheng (majors only) English

There are actually quite a few different ways to bring these data together.

Notice that the students in each table are overlapping. Some are in both tables (Shuyen and Brian), but others are only in one of the tables (Adnan in hobbies, Yungsheng in majors).

One thing we can’t do is (pretending we are in Excel land) we can’t just copy the major column and paste it next to the student_hobbies columns. If we do that, we will end up with an incorrect row that claims that Adnan is in the English major. So Yungsheng’s data ends up on Adnan’s row.

We also can’t copy both columns and paste them underneath. If we do that the values from major will end up in the hobby column.

But SQL databases know ways to combine the tables that preserve the meaning of the data.

How a join works: every pairing, then filter

How does SQL know which rows go together? It helps to think of a join as two steps: first pair up every row in one table with every row in the other, then keep only the pairings that make sense.

The first step is called a CROSS JOIN (or cross product):

SELECT *
FROM hobbies
  CROSS JOIN majors
hobbies
majors
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Shuyen (both tables) dancing Brian (both tables) Theater
Shuyen (both tables) dancing Yungsheng (majors only) English
Brian (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater
Brian (both tables) dancing Yungsheng (majors only) English
Adnan (hobbies only) running Shuyen (both tables) Art
Adnan (hobbies only) running Brian (both tables) Theater
Adnan (hobbies only) running Yungsheng (majors only) English

Nine rows: each of the 3 rows in hobbies paired with each of the 3 rows in majors. The colours show where each cell came from: each row of hobbies has its own shade of blue, and each row of majors its own shade of green. Reading down, each blue repeats 3 times while the greens cycle through all 3 majors rows. (The rows are sorted here to make that easy to see; when you run the query yourself they may come back in a different order.)

But … that’s odd, because most of these rows don’t reflect what we know. One row says Shuyen dances and is a Theater major, but that’s Brian’s major. Another pairs Adnan with Yungsheng’s English major. On the other hand, the table does contain all of the true rows (Shuyen with Art, Brian with Theater). This is like the string database from the card exercise: the true rows are the ones that follow a single string, not ones that veer off to connect to someone else.

So what condition would keep the true rows and eliminate the rest?

The true rows are those where the name from hobbies is the same as the name from majors. We already know how to eliminate rows with WHERE. Until now we have compared a column to a concrete value, like series = 'Harry Potter', but we can put a column name on both sides of the =. It is still a True or False question asked of each row, using the values in that row:

Row 1: 'Shuyen (both tables)' = 'Shuyen (both tables)'?    --> True
Row 2: 'Shuyen (both tables)' = 'Brian (both tables)'?     --> False
Row 3: 'Shuyen (both tables)' = 'Yungsheng (majors only)'? --> False
...
SELECT *
FROM hobbies
  CROSS JOIN majors
WHERE hobbies.name = majors.name
2 records
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater

Two rows, the true ones. Adnan and Yungsheng are gone because no pairing with them passes the condition.

Rather than writing the join condition in the WHERE, we usually write it with the JOIN, using ON. This gives exactly the same result:

SELECT *
FROM hobbies
  JOIN majors ON hobbies.name = majors.name
2 records
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater

Putting the join condition with the JOIN keeps it separate from the other conditions (like WHERE hobby = 'dancing') that we might add later.

Note

In older SQL, and in many answers online, you will see joins written with a comma between the tables and the join condition in the WHERE:

SELECT *
FROM hobbies, majors
WHERE hobbies.name = majors.name

This is the same as the cross join plus WHERE above. The JOIN keyword didn’t exist in early SQL (from the 1970s to the mid 1990s), which is why this is sometimes called the “old style” join, or an “implicit join”. In this course we use JOIN ... ON.

Inner and Outer Joins

When we join we have two kinds of rows:

  1. Those that were in both tables (as above)
  2. Those that were only in a single table

JOIN on its own (as above) only keeps those that were in both tables. This is called an INNER join.

If we also want to keep some of the rows that didn’t match, we can, and these are called OUTER joins.

Outer joins do this by filling in the columns from the other table with NULL for the rows that didn’t match. We also have to specify which unmatched rows we want to keep (only those from the LEFT table? only those from the RIGHT table? Or those from both?)

In this way, we will look at four different ways to combine these tables: 1) FULL JOIN, 2) LEFT JOIN, 3) RIGHT JOIN, and 4) INNER JOIN

Where do the NULLs come from?

We can extend our “every pairing, then filter” picture to explain the NULLs. Imagine that each table has one extra row that is completely empty (all NULL):

Show the code that creates the hobbies and majors tables with an extra NULL row
hobbies_plus_null <- tribble(
  ~name, ~hobby,
  "Shuyen (both tables)", "dancing",
  "Brian (both tables)", "dancing",
  "Adnan (hobbies only)", "running",
  NA, NA
)
dbWriteTable(con, "hobbies_plus_null", hobbies_plus_null, overwrite = TRUE)

majors_plus_null <- tribble(
  ~name, ~major,
  "Shuyen (both tables)", "Art",
  "Brian (both tables)", "Theater",
  "Yungsheng (majors only)", "English",
  NA, NA
)
dbWriteTable(con, "majors_plus_null", majors_plus_null, overwrite = TRUE)

hobbies_plus_null

name hobby
Shuyen (both tables) dancing
Brian (both tables) dancing
Adnan (hobbies only) running
NULL NULL

majors_plus_null

name major
Shuyen (both tables) Art
Brian (both tables) Theater
Yungsheng (majors only) English
NULL NULL

Now the cross join has 4 × 4 = 16 rows, and some of them pair a real row with the empty (grey) row:

SELECT *
FROM hobbies_plus_null
  CROSS JOIN majors_plus_null
hobbies
majors
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Shuyen (both tables) dancing Brian (both tables) Theater
Shuyen (both tables) dancing Yungsheng (majors only) English
Shuyen (both tables) dancing NULL NULL
Brian (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater
Brian (both tables) dancing Yungsheng (majors only) English
Brian (both tables) dancing NULL NULL
Adnan (hobbies only) running Shuyen (both tables) Art
Adnan (hobbies only) running Brian (both tables) Theater
Adnan (hobbies only) running Yungsheng (majors only) English
Adnan (hobbies only) running NULL NULL
NULL NULL Shuyen (both tables) Art
NULL NULL Brian (both tables) Theater
NULL NULL Yungsheng (majors only) English
NULL NULL NULL NULL

Every row of every join result is somewhere in those 16 rows. These are the four that matter:

hobbies name hobby majors name major kept by
Shuyen (both tables) dancing Shuyen (both tables) Art all four joins
Brian (both tables) dancing Brian (both tables) Theater all four joins
Adnan (hobbies only) running NULL NULL LEFT, FULL
NULL NULL Yungsheng (majors only) English RIGHT, FULL
  • INNER JOIN keeps only the pairings where the names match.
  • LEFT JOIN also keeps the pairing with the empty row for each row in the left table that found no match (Adnan).
  • RIGHT JOIN does the same for the right table (Yungsheng).
  • FULL JOIN does both.

Notice that Shuyen is also paired with the empty row, but that pairing is not kept, because Shuyen already found a match. The empty row is only used for rows that have no match at all. The pairing of the two empty rows is never kept.

This is a way of thinking about joins, not what the database actually does: it never builds all 16 rows.

FULL JOIN

LEFT JOIN

RIGHT JOIN

INNER JOIN

Images from https://learnsql.com/blog/sql-joins/

To keep the result tables below easy to read, they show a single name column. When you run these queries you will see two name columns, one from each table; see Join conditions (ON and USING) for why, and what to do about it.

FULL JOIN

The first keeps all of the data, matching up rows and adding NULL values where rows are only in one table or the other. This is called the FULL JOIN.

SELECT *
FROM hobbies 
  FULL JOIN majors ON hobbies.name = majors.name

full_join_result

name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Adnan (hobbies only) running NULL
Yungsheng (majors only) NULL English

LEFT and RIGHT JOIN

We can also keep all of the data from one table, and add in only the matching data from the other table. This is called the LEFT JOIN or the RIGHT JOIN. The direction just points to which table we will keep all the rows from.

LEFT JOIN

SELECT *
FROM hobbies 
  LEFT JOIN majors ON hobbies.name = majors.name

left_join_result

name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Adnan (hobbies only) running NULL

RIGHT JOIN

SELECT *
FROM hobbies 
  RIGHT JOIN majors ON hobbies.name = majors.name

right_join_result

name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Yungsheng (majors only) NULL English

These are really useful when we have data in a table about things and we want to “bring over” any data we have about those things.

Note

A RIGHT JOIN is identical to a LEFT JOIN with the tables reversed in order. This makes it almost never used as people would just rewrite it as a LEFT JOIN. So one generally doesn’t come across RIGHT JOIN. ie:

majors RIGHT JOIN hobbies

is identical to

hobbies LEFT JOIN majors

INNER JOIN

Finally, our fourth option is to only keep rows that have data in both tables. This is the INNER JOIN from above.

inner_join_result

name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater

Notice that, in some sense, the INNER JOIN drops data? Neither Adnan nor Yungsheng show up in these results at all. Sometimes that is appropriate, sometimes that is problematic.

Language confusion on Inner vs Outer

Joins are frequently talked about as being two kinds, inner and outer.

FULL, LEFT and RIGHT are all OUTER joins.

Because LEFT JOIN is by far the most used, if someone were to just say “outer join” then they almost always mean LEFT JOIN. OUTER JOIN on its own will throw an error.

INNER JOIN is the only inner join (there is only one way to include only the rows that match), it is used frequently enough that JOIN with no other word means INNER JOIN.

Note

R tidyverse has the same four joins, with matching names: full_join, left_join, right_join, and inner_join. The left_join we used for lookups in R is a LEFT JOIN.

JOIN synonyms

Square brackets shows optional part.

FULL [OUTER] JOIN
LEFT [OUTER] JOIN -- Often say "outer join"
RIGHT [OUTER] JOIN
[INNER] JOIN -- might just say just "join"
Caution

Many people are confused about the language here.

It is very common for people to think that a LEFT JOIN is not a kind of OUTER join. They may think that only a FULL OUTER JOIN is an OUTER JOIN.

They may become enraged if you insist on this.

Try to get comfortable with the two most common joins. INNER JOIN and LEFT JOIN. One useful way to think of this is:

INNER JOIN: All the rows where there is a match (and none of the rows where there is no match) LEFT JOIN: Keep everything in the left table, and add any matching rows from the other table. This is sometimes called “adding matching data, if any” and is used to do “lookups”.

Warning

Be aware that some people will say “join” when they are talking, but they will by default write LEFT JOIN, so they actually mean LEFT JOIN but they just say “join” (even though JOIN on its own is an inner join).

I dunno, it’s a mess. Don’t blame me.

At least LEFT JOIN never drops data (from the left hand table at least!)

Join conditions (ON and USING)

As we saw above, the ON clause is the filtering step: it says which columns have to match for a pairing of rows to be kept. The general form is

ON left_table.column = right_table.column

Running our LEFT JOIN from above:

SELECT *
FROM hobbies 
  LEFT JOIN majors ON hobbies.name = majors.name
3 records
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater
Adnan (hobbies only) running NA NA

Note that when we use ON we get back all of the columns from both tables, so there are two columns called name: one from hobbies and one from majors.

There is a special shorthand when the column name is the same in both tables, USING column_name. Making it:

SELECT *
FROM hobbies 
  LEFT JOIN majors USING (name) 
3 records
name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Adnan (hobbies only) running NA

One advantage of USING is that you only get one name column back. But it only works when columns are named exactly the same, something that often isn’t the case (including when working with id and foreign_id columns).

Here are the other three joins from above, now with a join condition:

SELECT *
FROM hobbies 
  RIGHT JOIN majors USING (name)
3 records
name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Yungsheng (majors only) NA English
SELECT *
FROM hobbies 
  INNER JOIN majors USING (name)
2 records
name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
SELECT *
FROM hobbies 
  FULL JOIN majors USING (name)
4 records
name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Adnan (hobbies only) running NA
Yungsheng (majors only) NA English

The two name columns are not a problem when doing a LEFT JOIN with ON, because the second name column only repeats content, so you can just drop or ignore it. But if you are doing a FULL OUTER JOIN with ON, the names from the left table are in one name column and the names from the right table are in the other, each with gaps:

SELECT *
FROM hobbies 
  FULL JOIN majors ON hobbies.name = majors.name
4 records
name hobby name major
Shuyen (both tables) dancing Shuyen (both tables) Art
Brian (both tables) dancing Brian (both tables) Theater
Adnan (hobbies only) running NA NA
NA NA Yungsheng (majors only) English

In this (rare but confusing) case, we can use COALESCE to merge the two name columns into one:

SELECT COALESCE(majors.name, hobbies.name) AS name, hobby, major
FROM hobbies
  FULL OUTER JOIN majors ON hobbies.name = majors.name
4 records
name hobby major
Shuyen (both tables) dancing Art
Brian (both tables) dancing Theater
Adnan (hobbies only) running NA
Yungsheng (majors only) NA English

COALESCE works by looking at each row, working left to right in the columns you give it, taking the first non-null value it encounters. Given that, the order you put the columns in the COALESCE doesn’t matter, when working with a FULL OUTER JOIN.

Looking up values

One important use of JOIN is to lookup the value of some code, such as abbreviations. This is a little like “find and replace,” using a second table to lookup values, doing many different “find and replace” all at once.

Consider these data:

countries

english_name iso_code3 iso_code2
China CHN CN
Australia AUS AU
United States of America USA US

Now we can use these to convert an iso_code to an english name for a country. Consider:

olympic_medals

country year_occurred medal_count
AU 1984 20
CN 1984 30
USA 1984 29
AU 1980 20
CN 1980 30
Show the code that creates the countries and olympic_medals tables
countries <- tribble(
  ~english_name, ~iso_code3, ~iso_code2,
  "China", "CHN", "CN",
  "Australia", "AUS", "AU",
  "United States of America", "USA", "US"
)
dbWriteTable(con, "countries", countries, overwrite = TRUE)

olympic_medals <- tribble(
  ~country, ~year_occurred, ~medal_count,
  "AU", 1984, 20,
  "CN", 1984, 30,
  "USA", 1984, 29,
  "AU", 1980, 20,
  "CN", 1980, 30
)
dbWriteTable(con, "olympic_medals", olympic_medals, overwrite = TRUE)

We can use a JOIN to lookup values in countries, or “bring over” the matching values from countries, so that each row that has “CN” has “China” in a new column, each row that has “AU” has “Australia” etc.

SELECT *
FROM olympic_medals
  LEFT JOIN countries ON olympic_medals.country = countries.iso_code2
5 records
country year_occurred medal_count english_name iso_code3 iso_code2
AU 1984 20 Australia AUS AU
CN 1984 30 China CHN CN
AU 1980 20 Australia AUS AU
CN 1980 30 China CHN CN
USA 1984 29 NA NA NA

You can see that using joins by default brings over all of the columns, but we can use SELECT to get just those we want, renaming using AS if we wish.

SELECT english_name AS country_name, year_occurred, medal_count
FROM olympic_medals
  LEFT JOIN countries ON olympic_medals.country = countries.iso_code2
5 records
country_name year_occurred medal_count
Australia 1984 20
China 1984 30
Australia 1980 20
China 1980 30
NA 1984 29

Note that I used a LEFT JOIN above. Look at the USA row in the results: it has no country_name. Its code, USA, is a three-letter code, but we joined on iso_code2, the two-letter codes, so nothing matched. Because we used LEFT JOIN, the row is kept and we can see the problem.

If I used just JOIN on its own I would get an inner join, and the USA row would disappear from the results entirely:

SELECT english_name AS country_name, year_occurred, medal_count
FROM olympic_medals
  JOIN countries ON olympic_medals.country = countries.iso_code2
4 records
country_name year_occurred medal_count
Australia 1984 20
China 1984 30
Australia 1980 20
China 1980 30

Four rows instead of five, with no error or warning. That isn’t usually what we want, so LEFT JOIN protects against that.

Discuss:

  1. Which row didn’t get matched up in the LEFT JOIN results? What does country_name show for it? How could we change the lookup so that it matches?

Talk it through, then read on.

Note

If someone is doing a “lookup” then they will almost always use a LEFT JOIN followed later in their workflow by dropping null values. That makes the dropping of data more explicit.

Fixing the lookup: one column of codes

The problem is that olympic_medals mixes two kinds of code, but our join can only look in one column of countries at a time. The countries table is wide: each country has its codes spread across two columns, iso_code3 and iso_code2.

If we take countries longer, using UNPIVOT (from SQL Transforms), all of the codes end up in a single code column, with one row per code:

UNPIVOT countries
ON iso_code3, iso_code2
INTO
    NAME code_type
    VALUE code
6 records
english_name code_type code
China iso_code3 CHN
China iso_code2 CN
Australia iso_code3 AUS
Australia iso_code2 AU
United States of America iso_code3 USA
United States of America iso_code2 US

Now every code, two-letter or three-letter, is in the same column. We save that as a new table:

CREATE OR REPLACE TABLE countries_long AS
  UNPIVOT countries
  ON iso_code3, iso_code2
  INTO
      NAME code_type
      VALUE code

and join on the code column:

SELECT english_name AS country_name, year_occurred, medal_count
FROM olympic_medals
  LEFT JOIN countries_long ON olympic_medals.country = countries_long.code
5 records
country_name year_occurred medal_count
China 1980 30
Australia 1980 20
United States of America 1984 29
China 1984 30
Australia 1984 20

All five rows are matched, including USA. Check the row count, though: we started with five rows in olympic_medals, and we still have five. That is because each code appears only once in the code column. If the same code appeared twice (say, as a two-letter code for one country and a three-letter code for another), a row could match twice, and the join would quietly add rows. Whenever you join, compare the number of rows before and after.

Exercises

For more practice, these interactive exercises run in the browser, no setup needed:

Add these data into duckdb:

country_medals <- tribble(
  ~country, ~gold_medal_count,
  "USA", 34,
  "South Korea", 13,
  "Australia", 33
)
dbWriteTable(con, "country_medals", country_medals, overwrite = TRUE)

country_regions <- tribble(
  ~english_name, ~region,
  "United States of America", "North America",
  "Korea, South", "East Asia",
  "Australia", "Oceania"
)
dbWriteTable(con, "country_regions", country_regions, overwrite = TRUE)

country_codes <- tribble(
  ~canonical_name, ~alternative_name,
  "United States", "United States",
  "United States", "USA",
  "United States", "US",
  "United States", "United States of America",
  "South Korea", "South Korea",
  "South Korea", "Korea, South",
  "Australia", "Commonwealth of Australia",
  "Australia", "Australia"
)
dbWriteTable(con, "country_codes", country_codes, overwrite = TRUE)
  1. Use a join to bring the region column to the country_medals table.
Show the query
  SELECT *
  FROM country_medals
    LEFT JOIN country_regions ON country_medals.country = country_regions.english_name
3 records
country gold_medal_count english_name region
Australia 33 Australia Oceania
South Korea 13 NA NA
USA 34 NA NA
  1. Talk with your neighbor about the result. Why is this not working well? How can the country_codes table help us?

  2. Use a join to add the appropriate canonical_name to the country_medals table

Show the query
  SELECT *
  FROM country_medals
    LEFT JOIN country_codes ON country_medals.country = country_codes.alternative_name
3 records
country gold_medal_count canonical_name alternative_name
USA 34 United States USA
South Korea 13 South Korea South Korea
Australia 33 Australia Australia
  1. Store the results of Q3 as a table called country_medals_canonical (using CREATE OR REPLACE TABLE, see Saving pipeline steps as tables).

  2. Use a similar join to lookup the canonical_name to the country_regions table and store that result in a country_regions_canonical table.

  3. Now use a join between those two tables to produce results with canonical_name, region, gold_medal_count columns.

Using JOIN with foreign key columns

In SQL database tables often use numbers as ids. These include both primary keys and foreign keys that refer back to the primary key. We will not cover this in our course, but it is something you should learn together with Entity Relationship diagrams and Normalization to restructuring data to become Third Normal Form

Here is an example of data from our initial exercise (the red mug and the green pen). We have split the data into two tables (objects) and colors. Here each table has a primary key id and there is one foreign key color_id.

objects

id name color_id
1 Mug 10
2 Pen 20

colors

id name
10 red
20 green
Show the code that creates the objects and colors tables
objects <- tribble(
  ~id, ~name, ~color_id,
  1, "Mug", 10,
  2, "Pen", 20
)
dbWriteTable(con, "objects", objects, overwrite = TRUE)

colors <- tribble(
  ~id, ~name,
  10, "red",
  20, "green"
)
dbWriteTable(con, "colors", colors, overwrite = TRUE)

We can use joins to lookup the color for each object. This is just like the case above, except instead of AU and CN we have the 10 and 20 labels in color_id.

SELECT *
FROM objects
  JOIN colors ON objects.color_id = colors.id
2 records
id name color_id id name
1 Mug 10 10 red
2 Pen 20 20 green

Note that both objects and colors have a column name (and a column id), so the results have two of each. SQL allows this in results tables, but it is confusing so we usually use AS to rename things.

SELECT objects.name AS object_name, 
       colors.name AS color_name
FROM objects
  JOIN colors ON objects.color_id = colors.id
2 records
object_name color_name
Mug red
Pen green

This pattern of joining with an ON condition that matches up a primary key and a foreign key is very common in databases designed with keys, and you will see it often. The id is used in other tables to represent a relationship with a row in a different table. This allows us to stitch back together data that is spread across databases.

You may notice that I’ve used JOIN (inner join) rather than LEFT JOIN (outer join) in this example. This is because when we have primary and foreign key columns we usually have “well-formed” data with “referential integrity” meaning that we don’t have “orphaned records”: every id in a foreign key column exists in the table it refers to. For example, we could add a new object, a purple pencil.

INSERT INTO objects VALUES (3, 'Pencil', 30)

(If you run that cell twice you will get two pencils; re-run the cell that creates the objects and colors tables to start again.)

But the colors table does not have a row for purple (30, purple), so beware that using just JOIN will silently drop our pencil.

You can watch out for this as you build up your sequence of queries, using -- sql comments to keep track of the number of rows.

SELECT *
FROM objects
--> 3 rows
3 records
id name color_id
1 Mug 10
2 Pen 20
3 Pencil 30
-- Now join to colors table to convert color_id to a color name.
SELECT *
FROM objects 
  JOIN colors ON objects.color_id = colors.id
--> 2 rows?????????????????????????
-- Why did this drop to 2 rows?
2 records
id name color_id id name
1 Mug 10 10 red
2 Pen 20 20 green
SELECT *
FROM objects 
  LEFT JOIN colors ON objects.color_id = colors.id
-- 3 rows.  hmmm, the pencil doesn't have a color. Are we missing a color definition?
3 records
id name color_id id name
1 Mug 10 10 red
2 Pen 20 20 green
3 Pencil 30 NA NA

Historically databases enforced “referential integrity” by marking foreign key columns with REFERENCES in the CREATE TABLE statement. This ensured that you could not insert a row with a “hanging” foreign key. If you tried the database would throw an error.

Today the fashion seems to be to avoid specifying these checks in the database itself. The argument for this approach is that these sort of checks are part of a wider set of validation requirements. For example, checking that a phone number matches a valid particular pattern, or a mailing address is valid, or an age is not negative. The tendency is to push validation outside the database, such as to javascript libraries (for data coming from web forms) or to test steps in data engineering toolchains.

In the data engineering and data warehouse world, this practice (of not specifying REFERENCES relationships in the table definitions) is reinforced in data engineering pipelines, because data is imported table by table, so there frequently are situations where foreign keys do not resolve, at least temporarily. The approach in data engineering is to import all data, then sort it out in the pipeline.

The takeaway here is that using LEFT JOIN when joining tables with primary and foreign keys is certainly a legitimate and important part of data validation and should probably be the default during data exploration.

The downside of using an outer join, though, is that all the records in the left table are retained. When working interactively doing data exploration, this can be problematic when the amount of data is large.

For example, you might be working with data like this in your projects. You might have a table with data for every city in the country … but you only have weather data for a few of them. In your project you probably want to drop data for which you don’t have matching data. So then you might just use JOIN rather than LEFT JOIN (which would leave you with lots of NULLs)

Using AS in joins (table abbreviations)

As you can see above, joins can get long and the ON conditions in particular are often hard to fit on one line. They get even longer if one adds conditions in the WHERE clause.

We can provide abbreviations to tables to shorten queries. We do this with FROM table AS t

For example, FROM objects AS o and JOIN colors AS c would shorten the query above somewhat:

SELECT o.name AS object_name,
       c.name AS color_name
FROM objects AS o 
  JOIN colors AS c ON o.color_id = c.id
2 records
object_name color_name
Mug red
Pen green

This is useful, although the definition of the abbreviation comes in the middle of the query, after it has already been used.

Aliasing tables is common and you will find it very frequently when searching online. However, reasonable people can disagree about its usefulness.

Pros: - Queries are faster to type - Queries can be more compact, and compactness helps legibility especially when line wraps are avoided

Cons:

  • Abbreviations can be ambiguous. They are not always obvious, especially when tables start with the same letters. Does p refer to people or purchases? Different analysts might resolve this in different ways, or even the same analyst may use different abbreviations in different queries with different combinations of tables.

  • It can be harder to share queries with others, as abbreviations might not be standardized across groups.

    “So p is always people. Well, unless purchases is also in the query, then I use pp for people and pr for purchases. But Katherine, I think, uses ppl or she used to. Oh, and if you join to peters_report try not to use pr? Got it?

  • Column names can’t be abbreviated, so advantages are partial.

  • It can be hard to remember abbreviations when reading queries with many tables, especially if you haven’t worked with the data frequently.

Given that SQL is usually written with tab completion for table names, and colored syntax highlighting, table aliasing may not be as important. Although, given that we write little snippets of SQL everywhere (Slack channels, web Q&A, email) and compactness can help spot errors it will probably persist.

Note

Notice the conventions used by those around you, ask what your team prefers.

Joining 3 or more tables

Joins are not limited to two tables. The “every pairing, then filter” idea works the same way with three, we just have more pairings and need more conditions.

Let’s add a third table about the students, home_towns. To keep things easy to read, the students who are in every table are now just Shuyen and Brian, and each table has one student who is only in that table.

Show the code that creates the hobbies, majors, and home_towns tables
hobbies <- tribble(
  ~name, ~hobby,
  "Shuyen", "dancing",
  "Brian", "dancing",
  "Adnan (hobbies only)", "running"
)
dbWriteTable(con, "hobbies", hobbies, overwrite = TRUE)

majors <- tribble(
  ~name, ~major,
  "Shuyen", "Art",
  "Brian", "Theater",
  "Yungsheng (majors only)", "English"
)
dbWriteTable(con, "majors", majors, overwrite = TRUE)

home_towns <- tribble(
  ~name, ~home_town,
  "Shuyen", "Austin",
  "Brian", "El Paso",
  "Marisol (home_towns only)", "Lubbock"
)
dbWriteTable(con, "home_towns", home_towns, overwrite = TRUE)

hobbies

name hobby
Shuyen dancing
Brian dancing
Adnan (hobbies only) running

majors

name major
Shuyen Art
Brian Theater
Yungsheng (majors only) English

home_towns

name home_town
Shuyen Austin
Brian El Paso
Marisol (home_towns only) Lubbock

(This replaces the hobbies and majors tables from the top of the page; re-run the cell that creates them there to get the originals back.)

First, the cross join of hobbies and majors, just as before: 3 × 3 = 9 rows.

SELECT *
FROM hobbies
  CROSS JOIN majors
hobbies
majors
name hobby name major
Shuyen dancing Shuyen Art
Shuyen dancing Brian Theater
Shuyen dancing Yungsheng (majors only) English
Brian dancing Shuyen Art
Brian dancing Brian Theater
Brian dancing Yungsheng (majors only) English
Adnan (hobbies only) running Shuyen Art
Adnan (hobbies only) running Brian Theater
Adnan (hobbies only) running Yungsheng (majors only) English

Now we cross join those 9 rows with home_towns. Each of the 9 pairings is paired with each of the 3 home towns (in pink), giving 9 × 3 = 27 rows:

SELECT *
FROM hobbies
  CROSS JOIN majors
  CROSS JOIN home_towns
hobbies
majors
home_towns
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Shuyen dancing Shuyen Art Brian El Paso
Shuyen dancing Shuyen Art Marisol (home_towns only) Lubbock
Shuyen dancing Brian Theater Shuyen Austin
Shuyen dancing Brian Theater Brian El Paso
Shuyen dancing Brian Theater Marisol (home_towns only) Lubbock
Shuyen dancing Yungsheng (majors only) English Shuyen Austin
Shuyen dancing Yungsheng (majors only) English Brian El Paso
Shuyen dancing Yungsheng (majors only) English Marisol (home_towns only) Lubbock
Brian dancing Shuyen Art Shuyen Austin
Brian dancing Shuyen Art Brian El Paso
Brian dancing Shuyen Art Marisol (home_towns only) Lubbock
Brian dancing Brian Theater Shuyen Austin
Brian dancing Brian Theater Brian El Paso
Brian dancing Brian Theater Marisol (home_towns only) Lubbock
Brian dancing Yungsheng (majors only) English Shuyen Austin
Brian dancing Yungsheng (majors only) English Brian El Paso
Brian dancing Yungsheng (majors only) English Marisol (home_towns only) Lubbock
Adnan (hobbies only) running Shuyen Art Shuyen Austin
Adnan (hobbies only) running Shuyen Art Brian El Paso
Adnan (hobbies only) running Shuyen Art Marisol (home_towns only) Lubbock
Adnan (hobbies only) running Brian Theater Shuyen Austin
Adnan (hobbies only) running Brian Theater Brian El Paso
Adnan (hobbies only) running Brian Theater Marisol (home_towns only) Lubbock
Adnan (hobbies only) running Yungsheng (majors only) English Shuyen Austin
Adnan (hobbies only) running Yungsheng (majors only) English Brian El Paso
Adnan (hobbies only) running Yungsheng (majors only) English Marisol (home_towns only) Lubbock

As before, most of these rows are not true. What if we use the same condition as before?

SELECT *
FROM hobbies
  CROSS JOIN majors
  CROSS JOIN home_towns
WHERE hobbies.name = majors.name
6 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Shuyen dancing Shuyen Art Brian El Paso
Shuyen dancing Shuyen Art Marisol (home_towns only) Lubbock
Brian dancing Brian Theater Shuyen Austin
Brian dancing Brian Theater Brian El Paso
Brian dancing Brian Theater Marisol (home_towns only) Lubbock

Six rows, not two. The condition matches up hobbies and majors, but nothing says which home town goes with which student, so Shuyen and Brian are each still paired with all three home towns. We need a second condition that ties home_towns to the others:

SELECT *
FROM hobbies
  CROSS JOIN majors
  CROSS JOIN home_towns
WHERE hobbies.name = majors.name
  AND majors.name = home_towns.name
2 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso

Two rows, the true ones. As before, we usually write each condition with its JOIN, using ON:

SELECT *
FROM hobbies
  JOIN majors ON hobbies.name = majors.name
  JOIN home_towns ON majors.name = home_towns.name
2 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso

Each table we add needs its own join condition, so joining 3 tables takes 2 conditions, 4 tables takes 3, and so on. If you forget one, you won’t get an error, just extra rows, so check the row count.

These are inner joins, so Adnan, Yungsheng, and Marisol are all dropped.

Each join attaches to the result so far

When a query has several joins, SQL works through them from left to right (top to bottom as we usually lay them out). Each JOIN attaches a new table to the table that resulted from the joins before it. So

FROM hobbies
  JOIN majors ON ...
  LEFT JOIN home_towns ON ...

means: first join hobbies and majors; then take that result and left join home_towns onto it. This is why the second ON can use columns from either hobbies or majors: both are already in the result so far.

We can make this order visible with parentheses:

SELECT *
FROM (hobbies
        JOIN majors ON hobbies.name = majors.name)
  LEFT JOIN home_towns ON majors.name = home_towns.name
2 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso

This is the same idea as mixing AND and OR in a WHERE (see Excluding rows): without parentheses there is a fixed order in which the pieces are resolved, and parentheses let us see, or change, that order. For AND and OR, AND is resolved first. For joins, it is left to right.

Thinking of joins this way explains a common surprise. Start with a LEFT JOIN, keeping Adnan even though he has no major:

SELECT *
FROM hobbies
  LEFT JOIN majors ON hobbies.name = majors.name
3 records
name hobby name major
Shuyen dancing Shuyen Art
Brian dancing Brian Theater
Adnan (hobbies only) running NA NA

Three rows, with NULLs for Adnan’s major. Now add home_towns with an inner JOIN:

SELECT *
FROM hobbies
  LEFT JOIN majors ON hobbies.name = majors.name
  JOIN home_towns ON majors.name = home_towns.name
2 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso

Adnan is gone, even though we asked for a LEFT JOIN. The inner join was attached to the three-row result, and on Adnan’s row majors.name is NULL, so majors.name = home_towns.name is not true and the row is dropped. An inner join after a left join can quietly undo the left join, with no error or warning.

If we want to keep Adnan, the second join has to be a LEFT JOIN too:

SELECT *
FROM hobbies
  LEFT JOIN majors ON hobbies.name = majors.name
  LEFT JOIN home_towns ON majors.name = home_towns.name
3 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso
Adnan (hobbies only) running NA NA NA NA

Or, just as parentheses can change the order of AND and OR, they can change which tables are joined first. Here majors and home_towns are joined first, and that result is left joined to hobbies, so Adnan is kept:

SELECT *
FROM hobbies
  LEFT JOIN (majors
               JOIN home_towns ON majors.name = home_towns.name)
    ON hobbies.name = majors.name
3 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso
Adnan (hobbies only) running NA NA NA NA

As always, check the number of rows after each join you add to help check that you are getting the number you expect. You can comment later joins out and evaluate step by step to identify where the number changes unexpectedly. E.g., with the query that lost Adnan, start with only the first table:

SELECT *
FROM hobbies
  -- LEFT JOIN majors ON hobbies.name = majors.name
  -- JOIN home_towns ON majors.name = home_towns.name
--> 3 rows
3 records
name hobby
Shuyen dancing
Brian dancing
Adnan (hobbies only) running

Then uncomment the first join:

SELECT *
FROM hobbies
  LEFT JOIN majors ON hobbies.name = majors.name
  -- JOIN home_towns ON majors.name = home_towns.name
--> 3 rows, still the same
3 records
name hobby name major
Shuyen dancing Shuyen Art
Brian dancing Brian Theater
Adnan (hobbies only) running NA NA

Then the second:

SELECT *
FROM hobbies
  LEFT JOIN majors ON hobbies.name = majors.name
  JOIN home_towns ON majors.name = home_towns.name
--> 2 rows?????? Adnan is gone. It was this join.
2 records
name hobby name major name home_town
Shuyen dancing Shuyen Art Shuyen Austin
Brian dancing Brian Theater Brian El Paso