Four rows, not eight. UNION keeps one row for each different customer_id.
UNION removes duplicates inside one list too, not only between the lists. Two real orders from Asha became one row.
To find duplicates, the database must compare all the rows, and that costs extra work. Use UNION ALL unless you want duplicates removed.
Rules for set operators
Both queries must have the same number of columns.
The columns match by position, not by name. The first column meets the first column.
The types must fit each other. PostgreSQL gives an error for integer and text. Some databases change the type without an error.
The result takes its column names from the first query.
Write ORDER BY once, at the very end. It sorts the whole result.
The next query breaks the first rule. The first query has one column and the second has two:
This failsworked example
SQL
SELECTcustomer_idFROMordersUNIONALLSELECTcustomer_id,nameFROMcustomers;-- ERROR: each UNION query must have the same number of columns
Watch out
If two columns swap places and their types fit, the query still runs. Every row from the second query then has a wrong label, and you get no error.
Interview question
“What is the difference between UNION and UNION ALL?” UNION removes duplicate rows and does extra work. UNION ALL keeps every row, so it is the usual choice.
03 · Comparing
INTERSECT & EXCEPT
INTERSECT keeps the shared rows. EXCEPT keeps the rows only in the first result.
⏱ 12 min · Topic 3 of 10
INTERSECT keeps the rows that are in both results. EXCEPT keeps the rows that are in the first result and not in the second.
Both compare whole rows. Both remove duplicates. We use lists A and B from the last section.
Customers 3 and 4 are in both lists. INTERSECT finds them:
List A has 1, 1, 3, 4. List B has 2, 3, 3, 4. Customers 3 and 4 are removed, and both rows for customer 1 stay.
PostgreSQL, MySQL 8.0.31 and later, Oracle 21c and later, and Databricks have the ALL versions. SQL Server, Snowflake, BigQuery and SQLite do not.
Older Oracle versions call EXCEPT by the name MINUS, and Oracle 21c and later accept both. BigQuery needs you to write EXCEPT DISTINCT or INTERSECT DISTINCT.
How NULL behaves
Chapter 1 taught that NULL = NULL is not true. Set operators are different. They treat two NULL values as the same value.
Look at the country of each customer in the two lists. Dev has no country, so NULL is in both lists:
List A has IN, IN, US and NULL. List B has GB, US, US and NULL. US and NULL are in both.
A join with ON a.country = b.country would lose Dev, because NULL = NULL is unknown. NOT IN has a worse problem. Chapter 1 showed that NOT IN returns no rows when its list has a NULL.
EXCEPT and NOT EXISTS avoid that problem, but they differ on NULL. On the country lists, A EXCEPT B gives only IN. NOT EXISTS with = gives IN and NULL, because NULL = NULL never matches.
Interview question
“How do you find the rows in A that are not in B?” Use EXCEPT or NOT EXISTS. Say how each treats NULL, and that NOT IN fails when the list has a NULL.
Watch out
If the two queries select values that never match, the result is empty. An empty result looks like a real answer. Run each query alone first.
04 · Pivoting
Rows into columns
A pivot needs one aggregate for each new column.
⏱ 9 min · Topic 4 of 10
A pivot turns the values of one column into column names. In orders, status has three values: paid, refunded and pending. A pivot gives one column for each.
You write one aggregate for each new column. Chapter 3 showed SUM with CASE. The CASE picks the rows for one column, and ELSE 0 gives 0 to the other rows:
One row per customer, one column per statusworked example
The grain is one row per customer. Omar has no orders, so he has no row.
Look at the cells for statuses a customer does not have. Customer 1 has no refunded order, so ELSE 0 gave 0. ROUND(…, 2) shows it as 0.00, like the other cells.
Now look at customer 3. The pending cell shows 0.00. But order 105 is pending, and its amount is unknown.
Order 104 gave a 0, and SUM skipped the NULL of order 105. So ELSE 0 hid the unknown amount.
The same idea with FILTER
Chapter 3 showed FILTER, a shorter form. It tells an aggregate to use only some rows. Here is what it gives when no rows pass the test:
Customer 2 has no paid order, so paid_orders is 0. Customers 1, 3 and 4 have no refund, so refunded_total is NULL.
FILTER does not have an ELSE. Write COALESCE(SUM(amount) FILTER (...), 0) if you want a 0.
PostgreSQL, SQLite (3.30 and later), DuckDB and Databricks have FILTER. MySQL, SQL Server and BigQuery do not, so SUM with CASE is the choice that works everywhere.
Watch out
You write the column list yourself. If a new status appears, it gets no column until you edit the query. A report tool (BI tool) or a program can build the query when the list changes.
05 · Dialect
Native PIVOT
PIVOT is a shorter way to write the same query. Not every database has it.
⏱ 7 min · Topic 5 of 10
Some databases have a PIVOT clause. It does the same job as SUM with CASE, with less typing. This table shows who has it:
Database
PIVOT?
Note
SQL Server
Yes
You list the values yourself.
Oracle
Yes
You list the values yourself.
Snowflake
Yes
It can also find the values with ANY.
BigQuery
Yes
You list the values yourself.
Databricks SQL
Yes
You list the values yourself.
PostgreSQL
No
The tablefunc extension has crosstab, which is different.
MySQL, SQLite
No
Use SUM with CASE.
The syntax is not the same in each database. This is the customer table from the last section, in SQL Server, which puts the new column names in square brackets:
PIVOT in SQL Serverworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- SQL Server. This does not run in PostgreSQL.SELECTcustomer_id,[paid],[refunded],[pending]FROM(SELECTcustomer_id,status,amountFROMorders)ASsourcePIVOT(SUM(amount)FORstatusIN([paid],[refunded],[pending]))ASpORDERBYcustomer_id;
Result · 4 rows
customer_id
paid
refunded
pending
1
65.00
NULL
NULL
2
NULL
60.00
NULL
3
15.50
NULL
NULL
4
80.00
NULL
NULL
The same four rows as before. Now every empty cell is NULL, because PIVOT has no ELSE 0.
Customer 3’s pending cell is NULL now. PIVOT adds only order 105, and its amount is unknown.
Wrap a column in COALESCE if you want 0 instead of NULL. That is one reason many people prefer SUM with CASE.
In SQL Server and Oracle, every source column that you do not pivot or add up becomes a group. Here, customer_id is the group.
Watch out
Add order_date to the source query, and each customer gets one row for each date. The pivot no longer shows one row per customer.
Interview question
“Pivot this table.” Write SUM with CASE, because it works in every database. Then say that SQL Server, Oracle, Snowflake, BigQuery and Databricks also have PIVOT.
06 · Unpivoting
Columns back into rows
Each column you collapse becomes one branch, or one row of VALUES.
⏱ 8 min · Topic 6 of 10
Unpivoting is the opposite of pivoting. Several columns become one column of values, and the old column names become text.
This made-up table is wide. It has one column for each status, and one row for each customer:
status_counts (4 rows)
customer_id
paid
refunded
pending
1
2
0
0
2
0
1
0
3
1
0
1
4
1
0
0
The way that works in every database is one SELECT for each column, joined with UNION ALL. Each SELECT writes the column name as text:
Three SELECTs, stackedworked example
SQL
Input data
status_counts4 rows
customer_id
paid
refunded
pending
1
2
0
0
2
0
1
0
3
1
0
1
4
1
0
0
A made-up table. It counts the orders of each customer by status. It is not part of the shop.
Three SELECTs on four rows give 12 rows. The status names are now data in a column.
Most of these rows have orders = 0. To keep only real orders, put the stack in a CTE (chapter 4) and filter it with WHERE orders > 0. That gives 5 rows, as the next query shows.
In PostgreSQL, VALUES writes rows by hand, inside the query. LATERAL lets those rows use the columns of the current row. Together they do the job in one pass over the table:
For each customer, VALUES makes three rows. WHERE keeps the five that have orders.
SQL Server writes this as CROSS APPLY (VALUES …). SQL Server, Oracle, Snowflake, BigQuery and Databricks also have an UNPIVOT clause, which drops NULL cells by default. MySQL and SQLite usually use UNION ALL, which keeps them.
If one column holds an array, UNNEST turns each item into a row. PostgreSQL and BigQuery have UNNEST.
Watch out
You type the column names yourself. A new column needs a new SELECT, or a new line in VALUES. Use UNION ALL, not UNION, which could remove rows.
07 · Subtotals
Subtotals: GROUPING SETS, ROLLUP & CUBE
ROLLUP adds subtotal rows. GROUPING() tells you which rows they are.
⏱ 12 min · Topic 7 of 10
A report often needs detail rows and a total in one result. A subtotal is a total for a part of the rows. ROLLUP adds the subtotals and a grand total.
This query groups the orders by status, and adds one grand total row:
Three groups plus one total row make four rows. The total row has NULL in status, because it covers all statuses.
A NULL with two meanings
Now group by country and status. Dev has no country, so a NULL country can mean two things. GROUPING(column) gives 1 when the NULL is in a subtotal or total row, and 0 when it is a real value:
The total row has a clear label, and is_total keeps it last.
The total row counts the orders table again. It does not add up the rows above it.
Watch out
As chapter 3 showed, counts of different values do not add up. Customers per status are 3, 1 and 1, which add to 5. A ROLLUP total row counts again and gives 4, the true number.
08 · Mistakes
Common reshaping mistakes
Check the row count, the empty cells and the totals.
⏱ 5 min · Topic 8 of 10
Reshaping queries rarely give an error. They give a result that looks fine and is wrong. Compare what you see with the usual cause:
What you see
Usual cause
Fix
Fewer rows after a stack
UNION removed duplicates.
Use UNION ALL.
More rows after a stack
UNION ALL kept duplicates.
Remove them on purpose, if they are not real.
An empty INTERSECT or EXCEPT
The values never match.
Run each query alone. Compare the first rows.
A NULL where you wanted 0
The aggregate had no rows to add.
Use ELSE 0 or COALESCE.
A 0 where the amount is unknown
ELSE 0 hid a NULL amount.
Decide if 0 or NULL is right.
A NULL label in a total row
ROLLUP uses NULL for “all”.
Add GROUPING() or a text label.
A total that does not match
The total came from different rows.
Calculate it from the same rows.
A new category has no column
The column list is fixed.
Edit the query, or build it from code.
Do three checks after every reshaping query:
Count the rows. Say the number you expect before you run the query.
Look at each empty cell. Is it 0, or is it NULL, and is that right?
For counts and sums, add up the detail rows. Does the sum match the total row?
Try check 3 on the two-column ROLLUP. The country subtotals are 60.00, 65.00, 15.50 and 80.00. They add up to 220.50, the grand total.
09 · Practice
Practice lab
Say the shape first. Then run the query and compare.
⏱ 7 min · Topic 9 of 10
Each exercise opens in the SQL workspace with its own tables. They use the online and in-store orders of a shop, not the six orders in this chapter.
They follow the order of this chapter. The unpivot exercise uses a CTE from chapter 4.
How to practise
Before you run a query, write the number of rows and the column names you expect. Then compare.