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 innames(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)) } elseif (!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) } elseif (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 onlytidyverse ="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 gtExtrasgtExtras ="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) chunkcheck_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 filedrv <-duckdb(shared_home =FALSE)con <-dbConnect(drv)
Course setup OK (SQL): DBI 1.3.0, duckdb 1.5.6
SQL Aggregate and Group
First we load our toy data on books.
books_with_weights <-tribble(~title, ~series, ~format, ~weight_in_grams,"Harry Potter 1", "Harry Potter", "paperback", 1456,"Two Towers", "Lord of the Rings","paperback", 1800,"Harry Potter 3", "Harry Potter","hardcover", 2604,"Harry Potter 2", "Harry Potter","hardcover", 2900,"Return of the King", "Lord of the Rings", "hardcover", 1784)# Write the dataset to DuckDBdbWriteTable(con, "books_with_weights", books_with_weights, overwrite =TRUE)
Recall that aggregation takes a set of rows and reduces them to a single value. In SQL this happens in the SELECT clause. Remember that the SELECT clause happens after the WHERE clause … so we are always aggregating the rows that are left.
We can obtain the total weight of all the books using the SUM function in the SELECT clause and an AS alias to name the result. We will build up to that query a step at a time. First, the whole table:
SELECT*FROM books_with_weights
5 records
title
series
format
weight_in_grams
Harry Potter 1
Harry Potter
paperback
1456
Two Towers
Lord of the Rings
paperback
1800
Harry Potter 3
Harry Potter
hardcover
2604
Harry Potter 2
Harry Potter
hardcover
2900
Return of the King
Lord of the Rings
hardcover
1784
Then just the column we want to aggregate:
SELECT weight_in_gramsFROM books_with_weights
5 records
weight_in_grams
1456
1800
2604
2900
1784
The aggregate function works to reduce this column to a single value.
SELECTSUM(weight_in_grams) AS total_weightFROM books_with_weights
1 records
total_weight
10544
If we have a WHERE clause this changes the rows that are involved (because SELECT executes after WHERE). First check which rows are left:
SELECT*FROM books_with_weightsWHERE series ='Harry Potter'
3 records
title
series
format
weight_in_grams
Harry Potter 1
Harry Potter
paperback
1456
Harry Potter 3
Harry Potter
hardcover
2604
Harry Potter 2
Harry Potter
hardcover
2900
Then aggregate just those rows:
SELECTSUM(weight_in_grams) AS total_weightFROM books_with_weightsWHERE series ='Harry Potter'
1 records
total_weight
6960
Three forms of COUNT
In SQL counting is done with COUNT. The grain of the books_with_weights data frame here is “one row represents a book”, so counting the rows produces a count of books. In SQL we count rows using COUNT(*).
SELECTSUM(weight_in_grams) AS total_weight,COUNT(*) AS book_countFROM books_with_weights
1 records
total_weight
book_count
10544
5
SQL has three forms of COUNT, but one is forbidden
COUNT(*) which counts the number of rows.
COUNT(DISTINCT column) which counts the number of distinct values in a column.
COUNT(column) counts the number of values that are not NULL.
COUNT(column) is the forbidden one. I will always grade that as wrong. COUNT(column) is forbidden because it is subject to subtle and confusing errors (and communicates poor understanding of what is being done).
Consider the differences between the three forms of COUNT on this list:
COUNT(*) would give the answer 4 (there are 4 rows).
COUNT(DISTINCT name) would give the answer 2 (just ‘Domhog’ and ‘Shuyen’)
COUNT(name) would give the answer 3 (there are 3 non-null values in the name column.)
I advise people to avoid COUNT(column) because the semantics are confusing in a query. I mean that what people are intending to accomplish is unclear. More, though, I haven’t come across a time when it is necessary.
Whenever you are tempted to use COUNT(column), ask yourself whether COUNT(*) is clearer and is actually what you mean. Most counting queries are questions about the number of rows (after some conditions/joins), not the number of non-NULL values.
ps. I’m very interested to find an example where COUNT(column) is the ideal thing, so please email me if you see one. Only thing I can think of is something like counting records with relationships. e.g. a table like people(id, name, sig_other_id) where those not in a relationship have NULL for their sig_other_id.
--How many people have sig others?SELECTCOUNT(sig_other_id) AS num_in_relationshipsFROM people
But I think even that would be much clearer as:
SELECTCOUNT(*) as num_in_relationshipsFROM peopleWHERE people.sig_other_id ISNOTNULL
So you can always re-write COUNT(column) as COUNT(*) with a WHERE ... IS NOT NULL clause. Much clearer.
If we wanted to know how many different manufacturers have cars in the gtcars dataset, in R tidyverse we used n_distinct(manufacturer) but in SQL we would use COUNT(DISTINCT manufacturer)
data(gtcars)gtcars_renamed <- gtcars %>%select(manufacturer = mfr, everything())# Write the dataset to DuckDBdbWriteTable(con, "gtcars", gtcars_renamed, overwrite =TRUE)
SELECTCOUNT(DISTINCT manufacturer) AS num_manufacturersFROM gtcars
1 records
num_manufacturers
19
A warning on MIN and MAX vs ORDER BY LIMIT 1
SQL offers aggregate functions called MIN() and MAX(). These seem straightforward, they find the minimum or maximum value in a column.
SELECTMIN(weight_in_grams) AS lightest_bookFROM books_with_weights
1 records
lightest_book
1456
Which will, correctly, return 1456, the lowest value in the column.
However questions about the ‘lowest’, ‘highest’, ‘earliest’ usually also want another part of the row with that value. e.g., ‘What’s the title of the lightest book?’, ‘Who bought the earliest ticket?’, or ‘Which product had the lowest price?’
These queries are better answered using ORDER BY and LIMIT 1. Using MIN and MAX the intuitive way can silently give a totally incorrect answer. DuckDB will refuse to run this query. Some other databases (SQLite, and MySQL in its older default settings) will run it: the result will look believable, but will have actually chosen an arbitrary value for title.
SELECT title,MIN(weight_in_grams) AS lightest_bookFROM books_with_weights
Error in `DBI::dbSendQuery()`:
! Binder Error: column "title" must appear in the GROUP BY clause or must be part of an aggregate function.
Either add it to the GROUP BY list, or use "ANY_VALUE(title)" if the exact value of "title" is not important.
LINE 1: SELECT title,
^
ℹ Context: rapi_prepare
ℹ Error type: BINDER
This bears repeating: If you use MIN or MAX in the SELECT clause with any other column you will not get the value in the row that had the lowest value, you will get an arbitrary value from the other column. I know that this is hard to believe. When an aggregate function is used in SELECT then we must be collapsing a group of rows … if we put a “bare” column name in the SELECT together with an aggregate function, then the system needs a way to pick a value. If the term is in the GROUP BY then it is clear, since all the values must be the same, but if the column is not then it will pick one arbitrarily …. or DuckDB asks us to force that behavior by using the ANY_VALUE(column) function, or throws an error and refuses to run.
While you should use ORDER BY and LIMIT 1, you can use MIN or MAX but you have to use it via a sub-query. Here think of the question as two steps: ‘What is the minimum weight? And what are the titles and weights of the books which have that weight?’
First get the minimum value:
SELECTMIN(weight_in_grams) FROM books_with_weights
1 records
min(weight_in_grams)
1456
That returns a single value which you then can use. You get the database to use it inside another query by surrounding the query with parens (this is called a sub-query; there is more on these in Arranging SQL transforms into a pipeline below).
SELECT title, weight_in_grams AS lightest_bookFROM books_with_weightsWHERE weight_in_grams = (SELECTMIN(weight_in_grams) FROM books_with_weights)
1 records
title
lightest_book
Harry Potter 1
1456
But hey if you use ORDER BY and LIMIT you can easily get the top three or bottom whatever (as long as ties don’t matter), so just don’t use MIN or MAX in the SELECT clause, unless it’s a sub-query. Here are the three lightest books:
I would only use MIN or MAX when ties really matter, and only in a sub-query. Even then there is a more direct approach to ranking, see Window Aggregates below.
GROUP BY
Recall that grouping is akin to sorting a dataframe, then drawing lines across it where the value in the grouping column changes.
title
series
format
weight_in_grams
Harry Potter 1
Harry Potter
paperback
1456
Harry Potter 3
Harry Potter
hardcover
2604
Harry Potter 2
Harry Potter
hardcover
2900
Two Towers
Lord of the Rings
paperback
1800
Return of the King
Lord of the Rings
hardcover
1784
Note
SQL only shows you the result of a finished query; there are no intermediate results along the way. So it pays to build queries up a step at a time: start with SELECT * FROM table, run it, check the rows, then add one clause and run it again. Keep an eye on the number of rows at each step.
On these pages each step is in its own cell, so you can see each intermediate result. In your own notebook you can also keep the earlier steps in the same cell, separated by semicolons (;). All of the queries run, but only the result of the last one is shown.
In SQL we will build up grouping queries using ORDER BY to see the rows before they are collapsed. To find the total weight of the books in each series, we first sort by series:
SELECT*FROM books_with_weightsORDERBY series
5 records
title
series
format
weight_in_grams
Harry Potter 1
Harry Potter
paperback
1456
Harry Potter 3
Harry Potter
hardcover
2604
Harry Potter 2
Harry Potter
hardcover
2900
Two Towers
Lord of the Rings
paperback
1800
Return of the King
Lord of the Rings
hardcover
1784
We will then change the ORDER BY to GROUP BY and copy the grouping column to the start of the SELECT. This gives one row per group:
SELECT seriesFROM books_with_weightsGROUPBY series
2 records
series
Harry Potter
Lord of the Rings
Finally we add the aggregate function:
SELECT series, SUM(weight_in_grams) AS total_weightFROM books_with_weightsGROUPBY series
2 records
series
total_weight
Lord of the Rings
3584
Harry Potter
6960
As before we can have multiple aggregate functions collapsing the same groups.
SELECT series, SUM(weight_in_grams) AS total_weight,COUNT(*) AS book_countFROM books_with_weightsGROUPBY series
2 records
series
total_weight
book_count
Harry Potter
6960
3
Lord of the Rings
3584
2
COUNT(DISTINCT column) in group
We can also look within a column and count the number of distinct things, as a way of collapsing the group. Switching to the cars data, we can count the number of different manufacturers for each year. Again we start by sorting, so we can see the groups (here just the columns we need):
SELECTyear, COUNT(DISTINCT manufacturer) AS manufacturer_countFROM gtcarsGROUPBYyear
4 records
year
manufacturer_count
2015
3
2014
2
2016
13
2017
8
Post Group and aggregate (ORDER BY and HAVING)
GROUP BY happens after FROM and WHERE (but before SELECT). Since WHERE happens early in the query, we can’t use it to exclude results from these results. And that is commonly a need, for queries that ask “Does any group have more than 1?”
We could pipeline these results into a temporary table, and then use WHERE on that, but SQL has a short cut here: the HAVING clause, which is a second WHERE that executes against the results of a query with a GROUP BY. For example we could find years in which more than 5 manufacturers produced models:
SELECTyear, COUNT(DISTINCT manufacturer) AS manufacturer_countFROM gtcarsGROUPBYyearHAVINGCOUNT(DISTINCT manufacturer) >5
2 records
year
manufacturer_count
2017
8
2016
13
We can use ORDER BY to change the order that the aggregated results are displayed (which is to say that ORDER BY happens after GROUP BY and even after HAVING).
SELECTyear, COUNT(DISTINCT manufacturer) AS manufacturer_countFROM gtcarsGROUPBYyearHAVINGCOUNT(DISTINCT manufacturer) >5ORDERBYyear
2 records
year
manufacturer_count
2016
13
2017
8
GROUP BY ALL
Above I made a big deal out of the step from ORDER BY to GROUP BY having two parts: change to GROUP BY and copy all the fields to the start of the SELECT. This is important, but it does mean that all the columns are repeated twice, which when queries are edited can lead to annoying mistakes.
DuckDB offers an improvement to SQL to address this, GROUP BY ALL which means that we can put our grouping columns in a single place (the SELECT) and then GROUP BY ALL uses all of the non-aggregate columns in the SELECT.
With our books data, grouping by both series and format:
SELECT series, format,SUM(weight_in_grams) AS total_weightFROM books_with_weightsGROUPBYALL-- same as GROUP BY series, format
4 records
series
format
total_weight
Lord of the Rings
hardcover
1784
Harry Potter
hardcover
5504
Harry Potter
paperback
1456
Lord of the Rings
paperback
1800
The DuckDB documentation uses this example (it is just for illustration; there is no star_wars_locations table to run it against).
SELECT systems, planets, cities, cantinas,sum(scum + villainy) AS total_scum_and_villainyFROM star_wars_locationsGROUPBYALL;-- GROUP BY systems, planets, cities, cantinas
Check out the duckdb documentation for other SQL improvements: https://duckdb.org/2022/05/04/friendlier-sql.html. But be mindful that these techniques may not be available in other dialects of SQL.
Arranging SQL transforms into a pipeline
Unlike R tidyverse and Python Pandas, SQL does not lend itself cleanly to creating transformation pipelines. Historically, SQL databases tended to just have the final schema in place, and other tools were used to transform the data on the way into the database. This is known as “Extract-Transform-Load” or ETL https://en.wikipedia.org/wiki/Extract,_transform,_load
In recent years, though, efforts to undertake transformations using SQL have accelerated, drawing on the very large data capabilities that are part of modern SQL databases.
Within a single query, SQL has two standard ways to use the results of one query inside another: sub-queries and CTEs.
A sub-query is a whole query, in parentheses, placed inside another query. We saw one above, finding the lightest book by putting SELECT MIN(weight_in_grams) FROM books_with_weights inside a WHERE clause. A sub-query can also go where a table name would normally go, in the FROM clause; we will see that in Window Aggregates below.
Sub-queries are a sort of unnamed temporary table. Because the syntax sticks a whole query right in the middle of another query, they are generally only used for short elements. Generally any complicated query should not be done as a sub-query, instead we turn to the CTE.
CTEs (aka WITH)
A CTE does the transformations at query time; you can think of this as a preamble to an SQL query which creates temporary tables.
WITH products_split_cte AS (SELECT STRING_SPLIT(product_num, '-')[1] AS product_code, STRING_SPLIT(product_num, '-')[2] AS product_num_no_code, product_nameFROM products )SELECT*FROM products_split_cteWHERE product_code ='FTW'
1 records
product_code
product_num_no_code
product_name
FTW
098756
Tennis shoes
You can even create multiple transformations via WITH, each referencing the last. For example, here we first split out the product codes, creating a temporary products_split_cte then we use that table to count the number of items with each code, creating a temporary product_counts_cte. Then we query product_counts_cte as though it was a table in the database.
WITH products_split_cte AS (SELECT STRING_SPLIT(product_num, '-')[1] AS product_code, STRING_SPLIT(product_num, '-')[2] AS product_num_no_code, product_nameFROM products ), product_counts_cte AS (SELECT product_code, COUNT(*) AS product_countFROM products_split_cteGROUPBY product_code )SELECT*FROM product_counts_cteWHERE product_code ='SPR'
1 records
product_code
product_count
SPR
3
You can use multiple ctes in the WITH block to build out a complex pipeline.
Note
When we use the WITH block to create temporary tables those are called CTEs. CTE stands for Common Table Expression, which doesn’t really explain much. The usual explanation is that these temporary table definitions are “common” to the rest of the query, in the sense of being available to any other part of it. See https://stackoverflow.com/questions/43564050/why-is-common-table-expression-called-common
The key thing here is to recognize that when people say, “Use a CTE” they mean “define a temporary table using the WITH block at the top of your query.
I find it useful to signal that I’m referencing a temporary table by adding the suffix _cte to the end of the tables I create this way.
Finally: you can also save intermediate results as tables, or use tools like DBT that manage a pipeline of tables; see Saving pipeline steps as tables in SQL Transforms Extras.
Window Aggregates
In R window aggregates (which return more than one row per group) are a fairly straightforward extension of the syntax used for basic group bys. For example, we can use slice_max to get the three most powerful cars (highest horsepower, hp) in each year.
In SQL, though, the concepts are the same but the syntax varies quite a bit from simple GROUP BY.
We have to use two steps. The first gets our rankings within a group (for each and every row), the second uses those rankings to exclude rows below a certain rank.
Getting the rank within a group uses the new syntax of OVER (PARTITION BY ... ORDER BY ...). We use ORDER BY hp DESC so that the most powerful car in each year gets rank 1. PARTITION BY divides the rows into groups just like GROUP BY, but it doesn’t collapse each group to one row: every row stays, and gets its rank within its group.
As with GROUP BY, first sort so we can see the groups and the order within them:
SELECT model,year, hpFROM gtcars ORDERBYyear, hp DESC
Displaying records 1 - 10
model
year
hp
458 Italia
2014
562
Gallardo
2014
550
LaFerrari
2015
949
F12Berlinetta
2015
731
Aventador
2015
700
FF
2015
652
Huracan
2015
610
458 Speciale
2015
597
458 Spider
2015
562
California
2015
553
Then add the ranking:
SELECT model,year, hp,row_number() OVER (PARTITIONBYyearORDERBY hp DESC) AS rank_in_yearFROM gtcars
Displaying records 1 - 10
model
year
hp
rank_in_year
458 Italia
2014
562
1
Gallardo
2014
550
2
LaFerrari
2015
949
1
F12Berlinetta
2015
731
2
Aventador
2015
700
3
FF
2015
652
4
Huracan
2015
610
5
458 Speciale
2015
597
6
458 Spider
2015
562
7
California
2015
553
8
The second step should be straightforward, using WHERE rank_in_year <= 3. But remember the order in which the parts of a query execute: FROM, then WHERE, then GROUP BY and HAVING, and only then SELECT. Window functions are calculated in the SELECT, so neither WHERE nor HAVING can see rank_in_year yet. If you try, DuckDB will tell you “WHERE clause cannot contain window functions!”
So we need a second query that works on the results of the first one: a small pipeline. We can use either of the options from Arranging SQL transforms into a pipeline.
With a sub-query, we copy the working ranking query into where the table name would normally go, like this:
SELECT*FROM (SELECT model,year, hp,row_number() OVER (PARTITIONBYyearORDERBY hp DESC) AS rank_in_yearFROM gtcars)WHERE rank_in_year <=3ORDERBYyear, rank_in_year
Displaying records 1 - 10
model
year
hp
rank_in_year
458 Italia
2014
562
1
Gallardo
2014
550
2
LaFerrari
2015
949
1
F12Berlinetta
2015
731
2
Aventador
2015
700
3
488 GTB
2016
661
1
Corvette
2016
650
2
Wraith
2016
624
3
GTC4Lusso
2017
680
1
GT
2017
647
2
Or with a CTE, we give the ranking query a name in the WITH block, then query it as though it were a table:
WITH hp_ranks_cte AS (SELECT model,year, hp,row_number() OVER (PARTITIONBYyearORDERBY hp DESC) AS rank_in_yearFROM gtcars)SELECT*FROM hp_ranks_cteWHERE rank_in_year <=3ORDERBYyear, rank_in_year
Displaying records 1 - 10
model
year
hp
rank_in_year
458 Italia
2014
562
1
Gallardo
2014
550
2
LaFerrari
2015
949
1
F12Berlinetta
2015
731
2
Aventador
2015
700
3
488 GTB
2016
661
1
Corvette
2016
650
2
Wraith
2016
624
3
GTC4Lusso
2017
680
1
GT
2017
647
2
Note
DuckDB (along with Snowflake, BigQuery, and Databricks) offers a third filtering clause, QUALIFY, which executes after the window functions are calculated. So WHERE filters rows, HAVING filters groups, and QUALIFY filters the results of window functions:
SELECT model,year, hp,row_number() OVER (PARTITIONBYyearORDERBY hp DESC) AS rank_in_yearFROM gtcarsQUALIFY rank_in_year <=3ORDERBYyear, rank_in_year
Many SQL dialects (including PostgreSQL, MySQL, and SQL Server) don’t have QUALIFY, so it is worth knowing the sub-query and CTE approaches above.
These continue with the pizzaplace data from the SQL Transforms review exercises. Remember that one row is one pizza sold, and id identifies the order that the pizza was part of. Load it into DuckDB again (each notebook starts with an empty database):
Build each query up a step at a time, and keep an eye on the number of rows.
How many pizzas were sold during the year? How many orders were there? How many different kinds of pizza (name) are on the menu? Answer all three in a single query.
What was the total revenue (price) for each type of pizza? Show the highest first.
Which sizes sold fewer than 1,000 pizzas?
How many orders were placed in each hour of the day? Reuse the STRING_SPLIT on time from review exercise 2 to make order_hour, and use GROUP BY ALL. When are the busiest hours?
Using a CTE, first make a table with one row per order, showing the number of pizzas and the total price of each order. Then use it to find the average number of pizzas per order, the average order total, and the largest order (most pizzas).
Stretch: find the best-selling pizza (most sold) within each type. You will need to count first, then rank.
Stretch: which three dates had the most orders? Can you think of a reason?
SQLZoo runs a different database, not DuckDB, so the DuckDB shortcuts on this page (GROUP BY ALL and QUALIFY) won’t work there. Use the standard forms: list the grouping columns in GROUP BY, and use a sub-query to filter on a ranking.