Shared course · by demo

SQL for people who already know spreadsheets

Move from pivot tables and VLOOKUP to writing SELECT, JOIN and GROUP BY against a real database, and understand why a query returns what it returns.

lessons
64
to learn
17h 3m
questions
204
written
64/64
Start reading BeginnerEnglish

The path

WrittenNot yet

  1. From a sheet to a table20 min3 questions
  2. Records and fields10 min3 questions
  3. Data types in SQL10 min3 questions
  4. The fixed-structure contract10 min3 questions
  5. Primary keys and uniqueness10 min3 questions
  6. SELECT and FROM: getting your data back25 min1 question
  7. SELECT and column list15 min1 question
  8. Column aliases with AS10 min3 questions

Lesson 1

From a sheet to a table

You will be able to explain what a relational database table is and how it differs from a spreadsheet. Covers rows, columns, data types, and the fixed-structure contract. Prevents treating a table like a sheet where any cell can hold anything.

20 min3 assessments

You've spent years in spreadsheets. You know that any cell can hold anything: a date, a number, a paragraph, or nothing at all. That flexibility is a feature when you're prototyping, but it becomes a problem when you need consistency, speed, and reliability across thousands of rows.

A relational database table replaces that free-form grid with a contract. Every column has a name, a data type (like INTEGER, TEXT, or DATE), and a fixed position in the row. The table's schema — the set of column definitions — is declared once when the table is created. Every row that follows must obey that schema. You cannot put a date in an INTEGER column, and you cannot add a new column just by typing in a cell below the last row.

This fixed structure is what makes databases fast and trustworthy. The database knows exactly what kind of data lives in each column, so it can index it, sort it, and join it without guessing. The contract also prevents the silent errors that spreadsheets are famous for — like a column that looks numeric but contains the text 'N/A' in a few cells, breaking your SUM formula.

In this node you'll learn to see the difference between a sheet and a table, and why that difference matters the moment you have more than one person entering data.

Spreadsheet vs Database Table structure

Analogy

Think of a spreadsheet as a junk drawer. You can toss in coins, receipts, paperclips, and takeout menus — all in the same compartment. Nothing stops you.

A database table is like a filing cabinet with clearly labeled drawers. One drawer is for 'Invoices' (all invoices), another for 'Customers' (all customer records). Each drawer holds only one kind of item. You cannot put a customer record in the invoice drawer, and you cannot stuff a receipt into the customer drawer without breaking the system.

Just as the filing cabinet makes it easy to find every invoice because they're all in the same place, a database table's fixed structure lets the database locate and sort data quickly.

Where the analogy breaks down

The analogy breaks down because a database table is stored digitally and can be indexed for near-instant retrieval, whereas a physical filing cabinet requires manual sorting. Also, a database can join multiple tables with complex queries, which is not like moving between drawers.

Common misconception

“A database table is just like a spreadsheet — you can put any value in any cell, and the structure is flexible.”

Both spreadsheets and database tables display data in a grid of rows and columns. In both, you can type a number, a date, or a word into a cell. The visual similarity is strong.

What is actually true

Database tables enforce a fixed schema: each column has a declared data type, and attempting to insert a value of the wrong type causes an error. This is not a limitation — it's a feature that guarantees data integrity and enables fast, predictable queries. Spreadsheets allow any value in any cell, which leads to silent errors and broken formulas.

  • A database table has a fixed schema: column names, data types, and order are defined upfront.
  • Each column accepts only one data type (INTEGER, TEXT, DATE, etc.), enforced by the database.
  • Every row in a table must conform to the same column structure; you cannot add a column by inserting a value.
  • Spreadsheets allow any cell to hold any type, leading to inconsistency and broken formulas.
  • The fixed structure of a database table enables indexing, fast queries, and reliable joins across tables.
Go deeper

Read the official PostgreSQL documentation on table basics to see how a schema is defined with CREATE TABLE and how data types are enforced.

Check yourself

Check your understanding of the differences between a spreadsheet and a database table.

1Multiple choiceChecked instantly

Which of the following is a key difference between a spreadsheet and a database table?

  • ABoth allow any data type in any cell.
  • BA database table enforces a fixed schema for columns, while a spreadsheet does not.
  • CSpreadsheets cannot have rows.
  • DDatabase tables cannot have columns.
2Short answerMarked by AI

Given a spreadsheet column that contains dates, numbers, and text in different rows, explain why this would be problematic if you tried to store it as a column in a database table. What would the database do if you attempted to insert a text value into an INTEGER column?

Graded on Identifies the schema mismatch · Describes the error or rejection · Connects to data integrity

3Short answerMarked by AI

A user argues that a database table is 'just like a spreadsheet' because both have rows and columns. Analyze this statement by identifying at least two structural differences that make the database table distinct. Explain how each difference affects data integrity.

Graded on Identifies two distinct structural differences · Explains impact on data integrity for each difference · Correctly evaluates the user's statement

Lesson 2

Records and fields

Understand that a table row is a record and a column is a field with a name and type. Contrast with spreadsheet cells that can contain anything.

10 min3 assessments

In a spreadsheet, a cell can hold anything: a number, a date typed as text, or the word "N/A". The same column might contain a mix of types. In a relational database, each column — called a field — has a name and an enforced data type (INTEGER, TEXT, DATE, etc.). Each row — called a record — is a single set of values that must exactly match that contract. If a field is defined as DATE, every record must provide a valid date (or a NULL placeholder — but NULL is still not "any text"). This contract makes the database predictable: you can query all values in a field knowing they are the same kind. In a spreadsheet you visually inspect; in a database the structure itself enforces consistency.

A table's fields define the structure; each record is a row that supplies one value per field.

Analogy

Think of a physical filing cabinet. Each drawer is a table. Each folder is a record. On every folder, the same fields are pre-printed: Name, Phone, etc. You fill in those blanks. The database approach is like having a folder with fixed fields — you can't decide to write a phone number in the Name blank.

Where the analogy breaks down

A folder in a cabinet can't hold multiple phone numbers without extra pages, but a database can handle one-to-many relationships through separate tables (like a PhoneNumbers table). In the analogy, you'd have to add sub-folders; the physical limits don't match the relational model.

  • A record is a complete row that contains exactly one value per field of the table.
  • A field is a column with a name and a declared data type.
  • Unlike spreadsheet cells, database fields enforce type — you cannot put text in a numeric field.
  • The table schema defines the fields; every record must adhere to it or be rejected.
  • NULL is a special marker meaning 'unknown' — not the same as an empty cell or missing data.

Spreadsheet row vs. database row

Spreadsheet (no contract) | Name | Age | Signup Date | |-----------|-----|-----------------| | Alice | 30 | 2023-01-15 | | Bob | 25 | January 15, 2023| | Charlie | 40 | missing |

Same data as a database table users

sql5 lines
CREATE TABLE users (
    name TEXT,
    age INTEGER,
    signup_date DATE
);

Only the first row would insert successfully. The second row's date is not in ISO format and would be rejected by the DATE type check. The third row's "missing" is not a valid DATE; you could instead provide NULL after adapting the data.

Check yourself

Check your understanding of records and fields.

1Multiple choiceChecked instantly

Which of the following best describes a record in a relational database?

  • AA single cell within a table
  • BA complete row containing one value per column
  • CA column containing all values for one field
  • DThe name and data type of a column
2Short answerMarked by AI

Given the table employees defined as:

sql5 lines
CREATE TABLE employees (
    id INTEGER,
    name TEXT,
    hire_date DATE
);

Which of these two spreadsheet rows can be inserted directly into the table? Explain why the other fails.

Row A: [101, "Alice", "2023-01-15"] Row B: [102, "Bob", "January 15, 2023"]

Graded on Identifies the correct row and the reason for failure · Demonstrates understanding of type enforcement

3Short answerMarked by AI

Why does a relational database enforce a fixed schema with typed fields, instead of allowing any value in any cell like a spreadsheet? Mention at least two consequences for querying or data integrity.

Graded on Explains the purpose of type enforcement · Lists at least two specific consequences

Lesson 3

Data types in SQL

Each column has a fixed data type (INTEGER, TEXT, DATE, etc.). This prevents the free-form cells of a spreadsheet.

10 min3 assessments

In a spreadsheet, a cell is a free-for-all: you can type "Alice" in a column you were using for dates, and the spreadsheet shrugs. A SQL table doesn't allow that. Every column has a fixed data type — a contract that says only values of this kind may live here.

You already know from From a sheet to a table that a table has a rigid structure. Data types are the next level of that rigidity. They stop you from accidentally summing text, comparing a date to a name, or silently converting a product ID into a number that loses leading zeros.

SQL data types fall into broad families:

  • Numeric – whole numbers (INTEGER), exact decimals (DECIMAL(p,s)), approximate (REAL).
  • Text – short strings (VARCHAR(n)), long ones (TEXT).
  • Date/Time – just the date (DATE), time (TIME), or both (DATETIME).
  • Other – binary data (BLOB), boolean (usually stored as INTEGER with 0/1), and more.

When you define a column, you pick its type once — and from that moment, every value that goes in must match. A mismatch causes an error, not a silent conversion.

Reference

SQL Data Types Reference

INTEGER

Description
Whole number, range –2^31 to 2^31–1
When to Use
IDs, counts, auto-increment columns (spreadsheet → column of row numbers)

DECIMAL(p,s)

Description
Fixed-point number: p total digits, s after decimal
When to Use
Currency, measurements (replaces number formatting with decimal places)

VARCHAR(n)

Description
Variable-length string up to n characters
When to Use
Names, emails, short codes (replaces plain text cells)

TEXT

Description
Large variable-length string (no practical limit)
When to Use
Comments, descriptions, logs (replaces long text in a single cell)

DATE

Description
Calendar date in YYYY-MM-DD format
When to Use
Birthdays, order dates (replaces date-formatted cells)

INTEGER (as boolean)

Description
0 or 1, often with a CHECK constraint
When to Use
Flags, yes/no fields (replaces checkbox or dropdown with True/False)

Actual type names and limits vary slightly by database (MySQL, PostgreSQL, SQLite). This table uses standard SQL supported by most.

Practice
0/8 known
1 / 8

Data Types in Action: The products Table

sql7 lines
CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(8,2),
    active INTEGER,      -- 0 or 1
    created DATE
);

Works:

sql2 lines
INSERT INTO products VALUES (1, 'Widget', 19.99, 1, '2025-03-15');
-- 1 row inserted

Fails:

sql2 lines
INSERT INTO products VALUES (2, 'Gadget', 'nineteen', 1, '2025-03-15');
-- ERROR: invalid input syntax for type DECIMAL: 'nineteen'

Because price expects a numeric value, the text 'nineteen' triggers an immediate error. No data is silently corrupted — the database protects itself and your data.

Go deeper
  • YouTube

    MySQL 24 - Important Data Types

    Caleb Curry·5:33·en

    Open
  • YouTube

    Understanding SQL Data Types: Strings, Integers, and Floats Explained

    DataCamp·4:15·en

    Open

Watch these short videos to see how different data types behave inside a live database.

Check yourself

Check your understanding of SQL data types.

1Multiple choiceChecked instantly

You are creating a table 'employees' and need a column for employee ID that automatically increments for each new row. Which data type should you choose?

  • AVARCHAR(10)
  • BINTEGER
  • CDECIMAL(8,0)
  • DDATE
2Select all that applyChecked instantly

Which of the following are valid ways to define a column that stores short variable-length text? (Select all that apply.)

  • AVARCHAR(255)
  • BCHAR(100)
  • CTEXT
  • DSTRING
  • EVARCHAR(2)
3Select all that applyChecked instantly

Which of the following statements correctly describe SQL data types? (Select all that apply.)

  • AAn INTEGER column can store the value 'ABC'.
  • BA DECIMAL(5,2) column can store 123.45.
  • CA DATE column stores both date and time.
  • DA VARCHAR(10) column can store a string of up to 10 characters.
  • EAn INTEGER column can store NULL unless constrained.

Lesson 4

The fixed-structure contract

A table's schema cannot be changed by a single row insertion; the structure is agreed upfront. This is why you cannot put a date in a numeric column.

10 min3 assessments

You already know that a table has columns with defined types. But why is that rigidity a feature, not a bug? In a spreadsheet, you can type a date into a column that started with numbers, and Excel will silently convert it. SQL databases refuse to do that. They enforce a fixed-structure contract: every column's data type is set when the table is created, and every row you insert must match that type exactly.

Consider a products table:

sql5 lines
CREATE TABLE products (
    id INTEGER,
    name TEXT,
    price NUMERIC
);

The contract says price can only hold numbers. If you try:

sql1 line
INSERT INTO products VALUES (1, 'Widget', '2024-01-01');

The database returns an error: invalid input syntax for type numeric. The string '2024-01-01' is not a number, so the contract is violated and the row is rejected. This might feel annoying if you're used to spreadsheets, but it protects your data from accidental corruption. A column that should hold prices will never contain a date, a phone number, or a customer name. The contract guarantees that every value in price is a valid number you can sum, average, or compare.

The database checks every value against the column's type before inserting. A mismatch triggers an error, enforcing the contract.

Analogy

Think of a table's schema like a rental lease agreement. The lease specifies that you must pay rent in US dollars on the 1st of each month. You cannot decide to pay in euros or on the 15th — the contract is fixed. Similarly, the schema specifies that the price column accepts only numbers. You cannot insert a date or a text string there, because that would violate the contract.

Just as a lease protects both landlord and tenant by setting clear expectations, the fixed-structure contract protects your data by ensuring every column contains only the kind of information it was designed for.

Where the analogy breaks down

A lease can be renegotiated (amended) with both parties' agreement. In SQL, you can alter a table's schema with `ALTER TABLE`, but that is a deliberate, explicit change — not something a single row insertion can do. Also, a lease is a mutual agreement between people, while a schema is a one-sided constraint imposed by the database designer.

Common misconception

“I can put a date in a numeric column because Excel treats dates as numbers.”

In Excel, dates are stored as serial numbers (e.g., 1/1/1900 is 1). When you type a date into a cell formatted as a number, Excel converts it to its serial number automatically. So it feels like dates and numbers are interchangeable.

What is actually true

SQL databases do not automatically convert between types. A column declared as NUMERIC will reject any value that is not a number — including date strings like '2024-01-01' or date objects. To store dates, you must use a DATE or TIMESTAMP column. The fixed-structure contract prevents the silent type mixing that Excel allows, which is exactly why your data stays clean and predictable.

  • A table's schema (column names and types) is defined before any data is inserted and cannot be changed by individual rows.
  • Each column has a fixed data type that all values in that column must match.
  • Inserting a value of the wrong type causes an error — the row is rejected.
  • This rigidity prevents data corruption and ensures that every value in a column is usable for calculations and comparisons.
  • Unlike spreadsheets, you cannot mix types (e.g., dates and numbers) in the same column.
  • The fixed-structure contract is a core principle that makes relational databases reliable and predictable.
Check yourself

Check your understanding of the fixed-structure contract.

1Multiple choiceChecked instantly

Which of the following best describes the fixed-structure contract in SQL?

  • AYou can change a column's data type at any time without affecting existing data.
  • BEach column has a predefined data type that all values in that column must match.
  • CA table's structure is determined by the first row of data inserted.
  • DSQL allows any data type in any column as long as the value is enclosed in quotes.
2Short answerMarked by AI

You have a table employees with columns id INTEGER, name TEXT, hire_date DATE. Write a single INSERT statement that will fail because of a type mismatch, and explain in one or two sentences why it fails.

Graded on Identifies a valid type mismatch · Explains the error

3Short answerMarked by AI

A colleague says: 'I can put a date in a numeric column because Excel treats dates as numbers.' Explain why this works in Excel but fails in SQL, and what the fixed-structure contract has to do with it.

Graded on Compares Excel and SQL behavior and references fixed-structure contract · Clarity and completeness of explanation

Lesson 5

Primary keys and uniqueness

A primary key uniquely identifies each row, similar to a row number in a spreadsheet but enforced by the database.

10 min3 assessments

In a spreadsheet, every row has a row number. That number seems like a perfect identifier — until you sort the data, delete a row, or copy a subset to another sheet. Suddenly the old row number points to a different record or nothing at all.

A database table needs a stable way to point at exactly one row, no matter what operations happen. That's what a primary key is for: a column (or set of columns) whose values are guaranteed to be unique and never missing. It's like a row number that the database itself enforces — you cannot create a duplicate, and you cannot leave it blank.

But what if you also need to ensure that another column — say, an email address — is unique? That's where the unique constraint comes in. It enforces uniqueness without taking on the special role of identifying the row.

Both primary keys and unique constraints prevent duplicate values, but they serve different purposes. Here is how they decide which to use.

Primary Key

Allows NULL values
No
Maximum per table
Exactly one
Default index type
Clustered (typically)
Purpose
Uniquely identifies each row

Best for Every table needs one; use it for the column that represents the row's identity (e.g., user_id, order_id).

Unique Constraint

Allows NULL values
Yes (one NULL allowed per column)
Maximum per table
Many
Default index type
Non-clustered
Purpose
Prevents duplicate values in a column or combination

Best for Columns that must be unique but are not the row's identifier (e.g., email, passport number).

Primary key and unique constraint in action

Consider a users table:

sql5 lines
CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT UNIQUE,
  name TEXT NOT NULL
);

Inserting valid rows:

sql2 lines
INSERT INTO users VALUES (1, '[email protected]', 'Alice');
INSERT INTO users VALUES (2, '[email protected]', 'Bob');

Both succeed. Now try to insert a duplicate primary key:

sql2 lines
INSERT INTO users VALUES (1, '[email protected]', 'Carol');
-- ERROR: UNIQUE constraint failed: users.id

The database rejects it. Now try a duplicate email:

sql2 lines
INSERT INTO users VALUES (3, '[email protected]', 'Alice2');
-- ERROR: UNIQUE constraint failed: users.email

Now try a NULL email (allowed because it's a unique constraint, not a primary key):

sql2 lines
INSERT INTO users VALUES (3, NULL, 'Charlie');
-- Succeeds (one NULL allowed)

But a second NULL email would fail:

sql2 lines
INSERT INTO users VALUES (4, NULL, 'Diana');
-- ERROR: UNIQUE constraint failed: users.email

The primary key on id ensures every row has a stable, non‑NULL identifier. The unique constraint on email prevents duplicate addresses without requiring everyone to have one.

Common misconception

“A primary key is just a column with a unique constraint.”

Both prevent duplicate values, and in many database tools you can add a unique constraint to any column, just like you can mark a column as a primary key.

What is actually true

A primary key is more than a unique constraint: it also identifies the row, cannot contain NULLs, and there can be only one per table. A unique constraint is a secondary rule that can be applied to many columns, allows one NULL, and does not designate the row's identity. Using a unique constraint where a primary key is needed can lead to missing rows in joins or broken referential integrity.

Decide

You are designing a customers table for an e‑commerce database. You need to choose which column will be the primary key. Consider these candidates:

  • customer_id — an auto‑incrementing integer that the system generates.
  • email — the customer's email address, which is unique and never shared.
  • phone — the customer's phone number, also unique per customer.

Which one should you pick?

Which column would you choose as the primary key?

Go deeper

Read the Wikipedia article on primary keys for a deeper look at composite keys and surrogate vs natural keys.

Check yourself

Check your understanding of primary keys and uniqueness.

1Multiple choiceChecked instantly

Which of the following is true about a primary key?

  • AIt can contain NULL values.
  • BThere can be multiple primary keys per table.
  • CIt uniquely identifies each row in the table.
  • DIt is optional; you can create a table without one.
2Select all that applyChecked instantly

Which of the following are properties of a unique constraint? (Select all that apply.)

  • AAllows at most one NULL value.
  • BCan be defined on multiple columns.
  • COnly one unique constraint is allowed per table.
  • DCreates an index to enforce uniqueness.
3Short answerMarked by AI

Explain why using an email column as a primary key might be a bad idea compared to using an auto-incrementing integer. Mention at least two reasons.

Graded on Identifies that email can change and why that's a problem · Identifies NULL issue or another valid reason

Lesson 6

SELECT and FROM: getting your data back

You will be able to write a SELECT statement that retrieves specific columns from a single table. Covers the basic SELECT and FROM clauses, column aliases, and the order of execution. Prevents the belief that SELECT runs first.

25 min1 assessment
  • SELECT retrieves data from a table; you specify which columns to return.
  • FROM names the table the data comes from; it is the first clause the database evaluates.
  • Column names in SELECT are separated by commas; a trailing comma causes an error.
  • Use * to select all columns, but avoid it in production code because it makes queries fragile and harder to read.
  • Column aliases (AS new_name) rename the output column; they are evaluated in the SELECT step, not before.
  • Because FROM runs first, you cannot use a column alias in the WHERE clause — the alias doesn't exist yet.

Retrieving specific columns from the employees table

Table: employees | id | name | department | salary | |----|------|------------|--------| | 1 | Alice | Sales | 55000 | | 2 | Bob | Engineering | 72000 | | 3 | Carol | Marketing | 48000 |

Query 1: Basic SELECT

sql1 line
SELECT name, salary FROM employees;

Result: | name | salary | |------|--------| | Alice | 55000 | | Bob | 72000 | | Carol | 48000 |

Query 2: With column aliases

sql1 line
SELECT name AS employee_name, salary AS annual_salary FROM employees;

Result: | employee_name | annual_salary | |---------------|---------------| | Alice | 55000 | | Bob | 72000 | | Carol | 48000 |

The output columns are renamed. The original column names in the table stay the same.

Steps
0/5

You'll write a SELECT statement step by step. We'll use a table called 'products' with columns: product_id, product_name, category, price, stock_quantity.

  1. Start with SELECT

    Type the keyword SELECT. It tells the database you want to retrieve data.

    Don't forget the capitalisation – SQL is case-insensitive but it's standard to write keywords in uppercase.

    You see `SELECT` at the start of your query.

  2. List the columns you want

    After SELECT, write the column names separated by commas. For example: product_name, price.

    A trailing comma after the last column name will cause an error. Double-check there's no comma after the final column.

    Your query looks like `SELECT product_name, price` (no comma after price).

  3. Add the FROM clause

    Type FROM followed by the table name: FROM products.

    The table name must match exactly (case-sensitive in some databases).

    Your query is now `SELECT product_name, price FROM products;`

  4. (Optional) Rename a column with AS

    To give the output column a different name, add AS after the column: price AS cost.

    The alias cannot be used in a WHERE clause (which runs before SELECT).

    Your query: `SELECT product_name, price AS cost FROM products;`

  5. Execute the query

    Run the query in your SQL tool or the sandbox. You should see the columns you selected, with any aliases applied.

    If you get an error, check for missing commas, misspelled column names, or a missing FROM clause.

    The result shows exactly the columns you asked for, in the order you listed them.

Checklist
0/6

Writing a SELECT statement

Keep this checklist open while you write your first queries.

Exercise1 check
sql

Write a query that retrieves only the product_name and price columns from the products table. Rename the price column to cost using an alias. The result should have two columns: product_name and cost.

Tables you can query

  • products (product_id, product_name, category, price, stock_quantity)
starter code1 line
SELECT * FROM products;

Lesson 7

SELECT and column list

Write a SELECT statement listing specific columns, separated by commas. Use * to select all columns but avoid it in production.

15 min1 assessment

In a spreadsheet, you click a column header to see its data. In SQL, you write the column names after SELECT. This is the column list.

sql2 lines
SELECT first_name, last_name
FROM employees;

That returns only first_name and last_name — nothing else. You can list any number of columns, separated by commas. The order you write them is the order they appear in the result.

You can also use * as a shortcut:

sql2 lines
SELECT *
FROM employees;

This returns every column in the table. It's handy when you're exploring, but dangerous in production: your application might break if the table structure changes, and you're always fetching more data than you need, wasting bandwidth and memory. A specific column list is safer and faster.

Steps
0/6

Write a query that returns only the name and price columns from a table called products.

  1. Start with SELECT

    Type SELECT followed by a space.

    Don't forget the space — `SELECTname` is a syntax error.

    Your cursor is one space after SELECT.

  2. List the first column

    Type name.

    Column names are case-insensitive in most databases, but stick to the exact case used in the table definition to avoid confusion.

    The word `name` appears after SELECT.

  3. Add a comma

    Type a comma ,.

    A trailing comma after the last column is a syntax error in SQL. Don't add one here yet.

    There is a comma after `name`.

  4. List the second column

    Type price.

    Make sure you spelled the column name correctly. `prices` (with an 's') might not exist.

    You have `SELECT name, price`.

  5. Add the FROM clause

    Type a space, then FROM products.

    The table name must match exactly. `product` (singular) might be a different table or not exist.

    Your query reads: `SELECT name, price FROM products`.

  6. Run the query

    Execute the statement. In most tools, you press Ctrl+Enter or click Run.

    If you get an error like "table not found", double-check the table name. If "column not found", check spelling and that the column exists in the table.

    You see a result set with two columns: `name` and `price`.

Checklist
0/6

Writing a SELECT with specific columns

Keep this list open while you write your first few queries. Tick each item before you run the query.

Exercise1 check
sql

Write a query that returns only the name and price columns from the products table. Do not use *. The result should have two columns, in the order: name, price.

Tables you can query

  • products (id, name, price, category)
starter code1 line
SELECT
  • A column list after SELECT specifies exactly which columns to return, in the order you write them.
  • Columns are separated by commas; no comma after the last column.
  • Using * returns all columns but should be avoided in production code.
  • The FROM clause names the table that contains the columns.
  • Column names are case-insensitive in most databases, but matching the exact case used in the schema is good practice.
Go deeper

Read the Wikipedia article on SELECT for more details on syntax and variations across database systems.

Lesson 8

Column aliases with AS

Rename columns in the output using AS, helpful for readability and when using joins later.

10 min3 assessments

You've written SELECT first_name, last_name FROM employees and the result shows exactly those column names — clear enough. But what happens when the column is called emp_salary_amt or the value is a calculation like (price * quantity)? The output column header stays the same or shows a raw expression. That's hard to read, especially when you share the results or build a report.

The AS keyword lets you rename any column in the output without touching the table. SQL executes the query, then applies the alias as the column header in the result set. It's a simple renaming step: you put AS right after the column name (or expression) followed by the alias you want.

Aliases shine in three places:

  • Giving a cryptic column a human-readable name.
  • Naming a computed column so it makes sense.
  • Preparing for JOIN later — when two tables both have a column called id, you can give each a distinct alias to tell them apart.

You already know SELECT … FROM. Adding AS is the next step to make your queries speak plainly.

Before and After: Renaming Columns

Table: employees | emp_name | emp_salary_amt | |----------------|----------------| | Alice Johnson | 75000 | | Bob Smith | 82000 |

Without alias – raw column names

sql1 line
SELECT emp_name, emp_salary_amt FROM employees;

Result columns: emp_name, emp_salary_amt

With alias – friendly names

sql1 line
SELECT emp_name AS "Employee Name", emp_salary_amt AS Salary FROM employees;

Result columns: Employee Name, Salary

The alias does not change the table. It only renames the column in the query output. Try it: run the same query again with different aliases and the table stays intact.

Steps
0/6

Write an alias for any column in a SELECT query. Follow these steps in order.

  1. Start the SELECT clause

    Type SELECT followed by the column or expression you want to rename. Example: SELECT emp_salary_amt

    Do not use commas inside the column expression; the alias comes after the whole expression.

    You have a valid column name or expression after SELECT.

  2. Add the AS keyword

    After the column, type a space then AS. Example: SELECT emp_salary_amt AS

    Omitting `AS` is allowed in most databases (you can write just `emp_salary_amt Salary`), but always including `AS` makes your intention clear and avoids bugs.

    You see `AS` in your query.

  3. Write the alias name

    Type the alias you want. If it contains spaces or special characters, enclose it in double quotes. Otherwise you can write it plain. Example: Salary or "Total Pay"

    Reserved words (like `ORDER`, `GROUP`, `DATE`) can be used as aliases only when quoted. Better to choose a non‑reserved name.

    The alias matches the column header you expect in the output.

  4. Repeat for more columns (optional)

    Add a comma, then repeat steps 1–3 for the next column. Example: SELECT emp_name AS Name, emp_salary_amt AS Salary

    Each column gets its own `AS`; you cannot alias a whole list.

    All columns you want renamed have `AS` and an alias.

  5. Add the FROM clause

    After all selected columns, type FROM table_name as usual. Example: SELECT emp_name AS Name, emp_salary_amt AS Salary FROM employees;

    Do not put a comma before `FROM`. A common mistake: `SELECT a, b, AS alias FROM` is wrong.

    The query ends with a semicolon and the table name is correct.

  6. Run and verify

    Execute the query. The result set should show the alias as the column header instead of the original name.

    If you see the original column name, you may have omitted `AS` or placed it incorrectly. Check the exact position.

    The output columns match your aliases exactly.

Checklist
0/5

Checklist for Using Column Aliases

Keep this open while writing queries. Tick each item before running.

Exercise1 check
sql

Modify the query so that:

  • customer is displayed as "Customer Name"
  • the computed column price * quantity is displayed as Total

Keep the FROM clause unchanged.

Tables you can query

  • orders (id, customer, product, price, quantity)
starter code4 lines
SELECT 
  customer,
  price * quantity
FROM orders;
Check yourself

Check your understanding of column aliases.

1Put in orderChecked instantly

Arrange the parts of a correct SELECT statement that renames a column called 'emp_name' to 'Name'.

  • ASELECT
  • Bemp_name
  • CAS
  • DName
  • EFROM
  • Femployees
2Short answerMarked by AI

Why would you use an alias for a calculated column? Provide one concrete SQL example and explain how the alias improves readability.

Graded on Identifies the purpose of an alias for computed columns · Provides a correct SQL example · Explains how the alias improves readability

Lesson 9

Logical order of execution

Understand that FROM is evaluated first, then SELECT. This explains why you cannot use a column alias in the WHERE clause.

10 min3 assessments

You've just learned to write SELECT name AS full_name FROM users. It works. But if you write SELECT name AS full_name FROM users WHERE full_name = 'Alice', the database throws an error: full_name is not recognized. Why?

The answer is the logical order of execution — the order in which a database conceptually processes a query. It's not the order you write the clauses in.

Clauses are evaluated in this order:

  1. 1.FROM — identify the source table(s)
  2. 2.WHERE — filter rows
  3. 3.GROUP BY — group rows (if used)
  4. 4.HAVING — filter groups
  5. 5.SELECT — project columns and compute expressions
  6. 6.ORDER BY — sort the result

Notice that SELECT comes after WHERE. That means any alias you define in SELECT — like full_name — doesn't exist yet when WHERE runs. The WHERE clause sees only the original column names from the table.

This is a conceptual model. The database engine may physically reorder steps for performance, but the result must match this logical order. Understanding it explains dozens of common SQL surprises.

Logical order of execution for a SQL query. Clauses are evaluated top to bottom.

Analogy

Think of preparing a fruit salad. First you gather all the fruit from the fridge (FROM). Then you pick only the ripe ones (WHERE). Only after that do you arrange them on a plate and label each piece (SELECT). If you tried to label a piece before you'd even picked it, the label wouldn't exist yet.

Where the analogy breaks down

The database optimizer can reorder operations for speed — for example, it might evaluate a simple WHERE filter before fully scanning the FROM table. The logical order guarantees the *result*, not the *execution plan*.

  • FROM is always evaluated first; it identifies the source data.
  • WHERE is evaluated before SELECT, so column aliases from SELECT are unavailable in WHERE.
  • SELECT is evaluated after filtering, which is why you can use aliases in ORDER BY (which runs after SELECT).
  • The logical order is a conceptual model; the physical execution may differ but must produce the same result.
  • Understanding execution order helps you debug errors like 'unknown column' in WHERE clauses.
  • YouTube

    SQL Order of Execution (Visually Explained) | Understanding How Queries Run

    This short animation walks through the same order of execution visually, reinforcing the concept with a live query example.

    Data with Baraa·4:04·en

    Open
Check yourself

Check your understanding of SQL's logical order of execution.

1Multiple choiceChecked instantly

Which clause is evaluated first in a SQL query?

  • ASELECT
  • BFROM
  • CWHERE
  • DORDER BY
2Short answerMarked by AI

Explain why the following query fails: SELECT salary * 1.1 AS raise FROM employees WHERE raise > 50000;

Graded on Identifies the order of execution · Connects to the specific alias

3Short answerMarked by AI

Rewrite the query from the previous question so it works correctly. Keep the alias 'raise' in the SELECT clause.

Graded on Uses original expression in WHERE · Preserves alias in SELECT

Lesson 10

Filtering rows with WHERE

You will be able to filter rows using comparison operators, AND, OR, IN, and BETWEEN, exactly like a spreadsheet filter. Covers the WHERE clause, operator precedence, and NULL behaviour. Prevents writing `WHERE column = NULL`.

30 min4 assessments

You can already write a SELECT statement that pulls columns from a table. But a table with 10,000 rows returns 10,000 rows — and you don't need all of them. In a spreadsheet you'd click the filter button and uncheck the rows you don't want. In SQL, the WHERE clause does the same thing: it keeps only the rows that pass a condition.

WHERE goes right after FROM (before ORDER BY or LIMIT). The condition is a logical expression that every row is tested against. If it's true, the row is included; if false or unknown (more on that later), it's dropped.

You already know the operators from spreadsheet formulas: =, <>, <, >, <=, >=. The biggest difference is that NULL — a missing value — behaves differently. WHERE column = NULL never works, because NULL = NULL is not true (it's unknown). You'll learn the right way to handle it in the next node, but for now, remember: never write = NULL.

Filtering a product table with WHERE

Table: products

idnamepricecategory
1Wireless Mouse25.99Electronics
2Notebook3.50Stationery
3USB-C Cable12.99Electronics
4Desk Lamp45.00Furniture
5Pen Set8.99Stationery

Query:

sql3 lines
SELECT name, price
FROM products
WHERE category = 'Electronics' AND price < 20;

Result:

nameprice
USB-C Cable12.99

Only the row with category 'Electronics' AND price less than 20 is returned. The Wireless Mouse costs 25.99, which is not < 20, so it's excluded.

Steps
0/5

Write a query that shows only employees who earn more than $50,000. You'll start with the employees table, then add conditions step by step.

  1. Start with SELECT and FROM

    sql2 lines
    SELECT first_name, last_name, salary
    FROM employees;

    This returns all rows. You'll narrow it down.

    If you forget the comma between columns, SQL will throw an error.

    Run the query and see all rows. Count them.

  2. Add a simple WHERE condition

    sql3 lines
    SELECT first_name, last_name, salary
    FROM employees
    WHERE salary > 50000;

    The > operator works exactly like in a spreadsheet.

    Don't use `salary > 50000` with an equals sign – that's a different operator (`>=`).

    Only rows with salary > 50000 appear. Verify the highest salary is included.

  3. Combine conditions with AND and OR

    sql3 lines
    SELECT first_name, last_name, salary, department
    FROM employees
    WHERE department = 'Sales' AND salary > 50000;

    Both conditions must be true. Use parentheses to group OR conditions: WHERE (department = 'Sales' OR department = 'Marketing') AND salary > 50000.

    `AND` has higher precedence than `OR`. `WHERE a OR b AND c` means `a OR (b AND c)`, not `(a OR b) AND c`. Always use parentheses when mixing them.

    Check that the result includes only rows that match both parts of the condition.

  4. Use IN and BETWEEN for cleaner filters

    sql4 lines
    SELECT first_name, last_name, salary
    FROM employees
    WHERE department IN ('Sales', 'Marketing')
      AND salary BETWEEN 40000 AND 60000;

    IN is a shorthand for multiple ORs. BETWEEN is inclusive of both ends.

    `BETWEEN` includes the boundary values. If you want to exclude one, use `>=` and `<` instead.

    The result should include employees in Sales or Marketing with salary 40000, 60000, and everything in between.

  5. Handle NULL values correctly

    sql3 lines
    SELECT first_name, last_name, salary
    FROM employees
    WHERE salary IS NOT NULL;

    NULL means the value is missing. Comparisons like = NULL or <> NULL always evaluate to unknown (not true), so the row is dropped. Use IS NULL or IS NOT NULL.

    Never write `WHERE salary = NULL`. It will never match any row, even if there are NULLs.

    The query should return rows where salary is not NULL. If you run `WHERE salary IS NULL`, you should see only rows with missing salary.

Checklist
0/6

WHERE clause checklist

Keep this open while writing your queries. Tick each item after you've checked your code.

Exercise2 checks
sql

Write a WHERE clause that returns employees in the Sales department whose salary is at least 50000. Use the >= operator and the department column. Include only the columns first_name, last_name, and salary.

Tables you can query

  • employees (id, first_name, last_name, salary, department)
starter code3 lines
SELECT first_name, last_name, salary
FROM employees
WHERE
Go deeper

For further reading, the Wikipedia article gives a general overview of SELECT, and the Microsoft documentation has many examples of WHERE with comparisons, AND/OR, IN, BETWEEN, and handling NULL.

Check yourself

Test your understanding of filtering with WHERE.

1Multiple choiceChecked instantly

Which of the following queries correctly filters employees whose salary is greater than 50000 and who are in the Sales department?

  • ASELECT * FROM employees WHERE salary > 50000 AND department = 'Sales';
  • BSELECT * FROM employees WHERE salary > 50000 OR department = 'Sales';
  • CSELECT * FROM employees WHERE salary > 50000, department = 'Sales';
  • DSELECT * FROM employees WHERE salary > 50000 AND department = Sales;
2Put in orderChecked instantly

Put the steps of writing a SELECT query with a WHERE clause in the correct order.

  • AWrite SELECT and list the columns you need
  • BWrite FROM and name the table
  • CWrite WHERE and the condition
  • DAdd ORDER BY if needed (optional)
  • EAdd LIMIT if needed (optional)
3Short answerMarked by AI

Write a query that returns the product_name and price from a table called 'products' for products whose price is between 10 and 20 (inclusive) and whose category is either 'Electronics' or 'Accessories'. Use BETWEEN and IN.

Graded on Correct use of SELECT and FROM · Correct implementation of BETWEEN for price range · Correct implementation of IN for categories · Overall correctness and readability

Lesson 11

What NULL really means

You will be able to explain why NULL is not zero and not an empty string, and write IS NULL and IS NOT NULL correctly. Covers three-valued logic and why `NULL = NULL` is false. Prevents the most common beginner logic bug.

15 min3 assessments

You’ve seen NULL in a spreadsheet cell: it’s the cell you never typed into. In SQL, NULL behaves nothing like zero or an empty string. It means “unknown” – a placeholder for a value that wasn’t recorded, doesn’t exist yet, or is inapplicable.

Because NULL is not a value, you can’t compare it with =, <, or <>. Any comparison where one side is NULL produces unknown, a third truth value besides true and false. The WHERE clause treats unknown exactly like false – the row is dropped.

That’s why WHERE salary = NULL never returns rows, even when salary is NULL. To find NULLs you must use IS NULL or IS NOT NULL. These operators only check whether something is unknown: NULL IS NULL yields true, 0 IS NULL yields false.

Three-valued logic also affects AND, OR, and NOT. For example, TRUE AND NULL is unknown; FALSE AND NULL is false (because false short‑circuits). NOT NULL is unknown. These rules feel strange at first, but they are consistent: every expression that touches a NULL can only stay unknown or become false – it never becomes true unless the result is forced by a non‑NULL value.

When you aggregate with COUNT, AVG, or SUM, NULLs are automatically skipped. COUNT(*) counts rows including NULLs; COUNT(column_name) counts only non‑NULL values. This difference is a common source of surprise, but it follows directly from the rule that NULL is not a value.

NULL comparisons yield UNKNOWN; only IS NULL converts it to TRUE

Analogy

Imagine a filing cabinet where each drawer has a label, but some drawers are empty – they have a folder but no paper inside. The empty folder (NULL) is not a paper that says “empty” (empty string), nor a paper that says “0” (the number zero). It is no information. If you check whether two empty folders are “equal”, you cannot answer – you don’t know what should have been inside, so they don’t count as equal. This is like SQL’s NULL = NULL returning unknown.

Where the analogy breaks down

When you try arithmetic, NULL does not behave like an empty folder that you can ignore. `empty folder + 1` makes no sense, but in SQL `NULL + 1` returns NULL, not 1. Similarly, string concatenation `'Hello' || NULL` returns NULL, not 'Hello'. The analogy’s “ignore it” breaks down in operations.

Common misconception

““NULL is the same as an empty string or zero – they all mean ‘nothing’.””

In a spreadsheet, empty cells are often treated as 0 or empty strings in formulas, and you rarely need to distinguish “blank” from “missing”. Many SQL beginners carry this over, assuming NULL is just another kind of empty.

What is actually true

NULL means unknown, a marker, not a value. The empty string '' is a concrete, known empty text; zero is a concrete number. NULL is neither equal to empty string nor zero: '' = NULL is unknown, 0 = NULL is unknown. This distinction matters in COUNT (skips NULL rows), string concatenation (NULL nullifies the result), and foreign key constraints (NULL allows missing but empty string does not).

  • NULL is not a value but an “unknown” marker; it cannot be compared using =, <, >, etc.
  • Any comparison involving NULL yields UNKNOWN (a third truth value), which WHERE treats as FALSE.
  • To test for NULL you must use IS NULL or IS NOT NULL.
  • Three-valued logic: TRUE AND NULL → UNKNOWN; FALSE AND NULL → FALSE; NOT NULL → UNKNOWN.
  • Aggregate functions (COUNT, AVG, SUM) skip NULLs by default, but COUNT(*) includes all rows.
Check yourself

Check your understanding of NULL and three-valued logic with these questions.

1Multiple choiceChecked instantly

In SQL, what does the expression NULL = NULL evaluate to?

  • ATRUE
  • BFALSE
  • CUNKNOWN
  • DNULL
2Fill the blanksChecked instantly
3Short answerMarked by AI

Suppose you have a table employees where the bonus column contains NULL values (meaning the employee did not receive a performance bonus). You write two queries:

Query A: SELECT * FROM employees WHERE bonus = 500; Query B: SELECT * FROM employees WHERE bonus IS NULL;

Query A returns no rows (even though there are rows with bonus = 500), while Query B returns the rows where bonus is NULL. Explain why Query A cannot be used to find employees with a bonus of 500. In your answer, reference the three-valued logic rule for NULL comparisons and how WHERE handles it.

Graded on Correctly explains that NULL comparisons yield UNKNOWN · Connects to WHERE clause behavior · Analyzes the purpose difference between Query A and B

Lesson 12

Comparison operators

Use =, <>, <, >, <=, >= to compare values. Note that comparisons with NULL always yield NULL, so use IS NULL instead.

10 min1 assessment
Steps
0/6

You know how filter rows in a spreadsheet. In SQL, the WHERE clause does that, and comparison operators are the rules you put inside it. Here's how each one works against a real database.

  1. Equal to (=)

    Write WHERE color = 'Red'. The single quotes are for text values, of column name. The query returns every row where color is exactly 'Red'. Case matters: 'red' not match unless the database is case-insensitive.

    Forgetting the quotes on a text value causes a syntax error. Numbers don't need quotes: `WHERE price = 10` works.

    Run the query and scan the results for rows that shouldn't be there.

  2. Not equal to (<>)

    Write WHERE color <> 'Red'. This returns rows where color is anything except 'Red'. Some databases also accept !=; stick with <> for portability.

    Rows where color is NULL are NOT returned. NULL is not equal to anything, including itself, so `<>` skips it.

    Check a row you know is NULL in that column — it should be missing from the results.

  3. Less than (<) and greater than (>)

    Write WHERE price < 50 for products under 50. WHERE price > 100 for products over 100. Works on numbers, dates, and even text alphabetically (WHERE name > 'M' starts after M).

    Don't mix types — `WHERE price > 'ten'` causes a database error or unexpected conversion.

    Verify the boundary: does price = 50 appear? (It shouldn't unless you use <=.)

  4. Less than or equal (<=), greater than or equal (>=) — the inclusive cousins

    Use WHERE price <= 50 to include the 50 boundary. Same pattern for >=. These are the exact mapping of spreadsheet 'filter by value between'.

    A common bug: `WHERE price > '10'` treats 10 as text, comparing lexicographically. The string '9' is greater than '10', so you get wrong results. Always use numeric literals for numeric columns.

    Add a row with price exactly 50 and confirm it shows up.

  5. The NULL trap: IS NULL

    To find rows where color is missing (no value), write WHERE color IS NULL. Do NOT write WHERE color = NULL — that always returns no rows because NULL = NULL yields NULL, not TRUE.

    The temptation is to use `= NULL` because it's intuitive. It never works. Use `IS NULL` and `IS NOT NULL` exclusively.

    Write both `= NULL` and `IS NULL` on the same table. The first returns nothing; the second returns the missing rows.

  6. Combining comparisons with AND/OR

    Write WHERE price > 10 AND price < 100 for products between 10 and 100 exclusive. Use OR for alternatives: WHERE color = 'Red' OR color = 'Blue'.

    AND binds tighter than OR. `WHERE color = 'Red' OR color = 'Blue' AND price > 50` gives rows that are Red (any price) or Blue over 50. Use parentheses: `WHERE (color = 'Red' OR color = 'Blue') AND price > 50`.

    Run the query without parentheses, then add them. Compare counts — they differ.

Checklist
0/7

SQL WHERE with comparisons — field checklist

Keep this open alongside your SQL tool. Tick each item after you write the query, before you run it.

Exercise2 checks
sql

Your manager wants a list of products that are priced between 15 and 80 (inclusive), have 'Red' or 'Blue' color, and have at least 1 item in stock. Write the WHERE clause with the correct comparison operators, parentheses, and IS NOT NULL handling — note that the product 'Socks' has a NULL color and should be excluded by your color condition. Your query should return exactly 2 rows.

Tables you can query

  • products (id, name, color, price, stock)
starter code3 lines
SELECT name, color, price, stock
FROM products
WHERE
  • Use = for equality, <> for not-equal, and <, >, <=, >= for ordered comparisons.
  • Single quotes are mandatory around text values; numeric values must never be quoted.
  • NULL comparisons always produce NULL (not TRUE/FALSE), so use IS NULL / IS NOT NULL to test for missing values.
  • = NULL always returns no rows; this is the most common beginner mistake.
  • AND has higher precedence than OR. Use parentheses to force the grouping you intend.
  • Comparisons on text are lexicographic unless the column is numeric or date-typed.

Finding red products under $20 that are in stock

Table: inventory | id | name | color | price | quantity | |----|--------|-------|-------|----------| | 1 | Shirt | Red | 15 | 10 | | 2 | Hat | Red | 12 | 0 | | 3 | Jacket | Blue | 75 | 5 | | 4 | Socks | NULL | 5 | 50 |

Query:

sql5 lines
SELECT name, price
FROM inventory
WHERE color = 'Red'
  AND price < 20
  AND quantity > 0;

Result: | name | price | |-------|-------| | Shirt | 15 |

Why: Hat is Red but quantity is 0 so it's excluded. Jacket is Blue. Socks has NULL color — the equality check color = 'Red' fails, so it's correctly excluded.

  • YouTube

    SQL Comparison Operators | Oracle SQL fundamentals

    Watch this to see the same SQL comparison operators demonstrated on a live Oracle database with real result sets — the walkthrough makes the NULL trap visually clear.

    DBA Genesis·5:53·from 0:30·en

    Open

Lesson 13

Logical operators: AND, OR, NOT

Combine conditions with AND, OR, NOT. Understand operator precedence and use parentheses to clarify logic.

10 min3 assessments

When you filter a spreadsheet, you often combine conditions: rows where status is "Shipped" and amount is over $100, or rows where status is "Pending" or priority is "High". SQL does the same thing with the words AND, OR, and NOT.

  • AND – both conditions must be true (makes the result narrower).
  • OR – at least one condition must be true (makes the result broader).
  • NOT – flips a condition (excludes matching rows).

A key difference from spreadsheets: SQL evaluates AND before OR by default, just like multiplication before addition. If you write status = 'Shipped' OR status = 'Pending' AND amount > 100, SQL sees status = 'Shipped' OR (status = 'Pending' AND amount > 100). To group OR conditions, you must use parentheses. Forgetting them is the most common logical operator mistake.

Steps
0/5

You have an orders table with columns: id, customer, status, amount, priority. Let's combine conditions step by step.

  1. Filter with AND

    Write: SELECT * FROM orders WHERE status = 'Shipped' AND amount > 100; SQL returns only rows where both conditions hold. Check the count: it's smaller than either condition alone.

    If you use `=` on a NULL column, the condition is false (NULL is not equal to anything).

    The result should have fewer rows than the table.

  2. Add an OR condition

    Write: SELECT * FROM orders WHERE status = 'Shipped' OR priority = 'High'; Now rows with either condition appear – result is wider.

    The result count should be larger than step 1.

  3. Combine AND and OR correctly

    You want orders that are either Shipped or Pending AND have amount > 50. Write: SELECT * FROM orders WHERE (status = 'Shipped' OR status = 'Pending') AND amount > 50; Without parentheses SQL would interpret as status = 'Shipped' OR (status = 'Pending' AND amount > 50) – wrong set.

    Missing parentheses is the most common bug here. Always add them around OR groups when combined with AND.

    Compare with the unparenthesised version; the results differ.

  4. Use NOT to exclude

    Write: SELECT * FROM orders WHERE NOT status = 'Cancelled'; This is the same as status <> 'Cancelled'. NOT is often clearer, especially with complex conditions.

    `NOT` applies only to the immediate condition. To negate a group, use `NOT (condition1 OR condition2)`.

    Verify no 'Cancelled' rows appear.

  5. Build a combined query

    Now combine everything: orders from Departments 'Sales' or 'Marketing', amount over 100, not from year 2021. SELECT * FROM orders WHERE (department = 'Sales' OR department = 'Marketing') AND amount > 100 AND NOT year = 2021;

    If you omit the parentheses around the OR, you'll get Sales rows from any amount/year.

    Run a simplified query to test each piece: `SELECT * FROM orders WHERE department = 'Sales';` then add conditions one at a time.

Checklist
0/5

Checklist for writing WHERE clauses with logical operators

Keep this list next to you while writing complex filters.

Exercise3 checks
sql

Write three separate SELECT queries on the employees table. Each query must return the correct set of rows.

  1. 1.Sales with high salary: employees in the 'Sales' department AND salary greater than 50000.
  2. 2.Engineering or low salary: employees in the 'Engineering' department OR salary less than 30000.
  3. 3.Not HR and recent hires: employees NOT in the 'HR' department AND hired after 2020-01-01.

Tables you can query

  • employees (id, name, department, salary, hire_date)
starter code7 lines
-- Write your queries below:

-- Query 1: Employees in Sales with salary > 50000

-- Query 2: Employees in Engineering or with salary < 30000

-- Query 3: Employees NOT in HR and hired after 2020-01-01
Check yourself

Test your understanding of combining conditions with logical operators.

1Put in orderChecked instantly

You need to write a query to find orders that are (status = 'Shipped' OR status = 'Pending') AND amount > 100. Put the steps in the correct order to evaluate the conditions for each row.

  • ACheck if amount > 100
  • BCombine result of OR with AND
  • CCheck if status is 'Shipped' OR status is 'Pending'
  • DReturn row if final result is TRUE
2Short answerMarked by AI

A query is written without parentheses: SELECT * FROM products WHERE category = 'Electronics' OR category = 'Clothing' AND price < 50. A student expects it to return all Electronics and all cheap Clothing. But the result includes all Electronics (even expensive ones) and only Clothing under $50. Explain why the student's expectation was wrong, using the precedence rule.

Graded on Identifies precedence rule (AND before OR) · Explains the actual result · Suggests correct fix with parentheses

Lesson 14

IN and BETWEEN

Simplify multiple OR conditions with IN and range checks with BETWEEN. Both are inclusive where appropriate.

10 min4 assessments

IN and BETWEEN: Shorter, clearer filters

You already know how to write WHERE department = 'Sales' OR department = 'Marketing'. That works, but it gets long fast. IN lets you collapse multiple OR conditions into one list:

sql2 lines
SELECT * FROM employees
WHERE department IN ('Sales', 'Marketing');

BETWEEN does the same for a range check — instead of salary >= 50000 AND salary <= 80000, you write:

sql2 lines
SELECT * FROM employees
WHERE salary BETWEEN 50000 AND 80000;

Both are inclusive: BETWEEN includes the endpoints, and IN matches every value in the list exactly. They work with numbers, text, and dates. Keep in mind:

  • IN with a NULL in the list (IN ('Sales', NULL)) does not match NULL rows — it returns unknown, and the row is excluded.
  • BETWEEN is always low AND high — swapping the order gives no rows.
  • NOT IN and NOT BETWEEN invert the logic.

Filtering products and orders

Table: products

idnamecategoryprice
1LaptopElectronics1200
2T-shirtClothing25
3NotebookStationery5
4HeadphonesElectronics80
5NovelBooks15

Query 1: IN

sql2 lines
SELECT name, price FROM products
WHERE category IN ('Electronics', 'Books');

Result:

nameprice
Laptop1200
Headphones80
Novel15

Table: orders

idcustomerorder_dateamount
1Alice2023-06-15150
2Bob2023-12-31200
3Carol2024-01-0175

Query 2: BETWEEN

sql2 lines
SELECT customer, amount FROM orders
WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31';

Result:

customeramount
Alice150
Bob200

Note that December 31 is included. January 1, 2024 is not.

Steps
0/4

You'll write a query that finds employees in specific departments earning within a certain range.

  1. Start with SELECT and FROM

    Write SELECT first_name, last_name, salary, department FROM employees.

    Don't forget the comma between columns.

    You see the column names in your results panel.

  2. Add the IN filter

    Add WHERE department IN ('Engineering', 'Support') after the FROM clause.

    Use single quotes around text values. Commas between items, no trailing comma.

    Run the query — you should see only Engineering and Support employees.

  3. Add the BETWEEN filter

    Extend the WHERE clause with AND salary BETWEEN 45000 AND 75000.

    BETWEEN is inclusive. If you want to exclude 45000 or 75000, use `>=` and `<=` instead.

    The result set now shows only employees in those departments with salary from 45000 to 75000 inclusive.

  4. Check for NULLs

    If any employee has a NULL salary or NULL department, they are excluded. Verify by adding OR salary IS NULL temporarily to see if any rows appear.

    NULL in an IN list (`IN ('Sales', NULL)`) does NOT match NULL rows. Use `IS NULL` separately.

    Your final query returns only the rows you expect. Count them against a manual check.

Checklist
0/6

IN and BETWEEN usage checklist

Keep this open while writing your queries.

Exercise2 checks
sql

Write a query that returns employees in the 'Sales' or 'Marketing' department whose salary is between 50000 and 80000 (inclusive). Return all columns.

Tables you can query

  • employees (id, first_name, last_name, department, salary)
starter code3 lines
SELECT first_name, last_name, salary, department
FROM employees
WHERE
Check yourself

Check your understanding of IN and BETWEEN.

1Multiple choiceChecked instantly

Which statement about BETWEEN is true?

  • ABETWEEN excludes the lower bound.
  • BBETWEEN includes both the lower and upper bounds.
  • CBETWEEN only works with numeric columns.
  • DBETWEEN cannot be used with dates.
2Put in orderChecked instantly

Arrange the steps to write a query using IN and BETWEEN in the correct order.

  • AWrite SELECT columns
  • BWrite FROM table
  • CWrite WHERE column IN (list)
  • DAdd AND column BETWEEN value1 AND value2
  • EExecute the query
3Short answerMarked by AI

You run SELECT * FROM employees WHERE department IN ('Sales', 'Marketing') AND salary BETWEEN 50000 AND 80000; and get zero rows, but you know there is an employee in Sales with salary 55000. What could be wrong? Explain at least two possible causes.

Graded on Identifies at least two plausible causes · Explains why the cause leads to zero rows

Lesson 15

String matching with LIKE

Use LIKE with % and _ wildcards for pattern matching. Understand case sensitivity differences across databases.

10 min4 assessments

You already know how to filter rows with WHERE and exact values like =, >, or IN. But what if you need to find every customer whose email ends with @gmail.com? In a spreadsheet you'd use a filter with "contains" or a formula like SEARCH. In SQL, you use LIKE with wildcards.

LIKE compares a column to a pattern. Two wildcards do the heavy lifting:

  • % matches any sequence of zero or more characters.
  • _ matches exactly one character.

For example, WHERE email LIKE '%@gmail.com' finds rows where the email column ends with @gmail.com. The % before it means "anything can come first."

Case sensitivity varies by database. In SQLite and PostgreSQL, LIKE is case-insensitive for ASCII letters by default. In MySQL, it depends on the column's collation (often case-insensitive). In SQL Server, it's case-insensitive unless the database is configured otherwise. When in doubt, use LOWER(column) LIKE LOWER(pattern) to force a case-insensitive match.

Steps
0/5

You need to find customers whose email domain is gmail.com. The table is called customers with columns id, name, email.

  1. Write the basic SELECT with WHERE

    Start with SELECT name, email FROM customers WHERE email LIKE.

    Forgetting the `WHERE` clause returns all rows.

    The query runs without errors.

  2. Add the pattern with %

    Complete the clause: WHERE email LIKE '%@gmail.com'. The % before @ means any characters can appear before the domain.

    Leaving off the `%` makes it an exact match – `'@gmail.com'` would only match rows where the email is literally `@gmail.com`.

    Run the query and verify it returns customers with gmail addresses.

  3. Test with a single-character wildcard _

    Suppose you want names that are exactly 4 letters and start with 'J'. Use WHERE name LIKE 'J___' (J followed by three underscores). Each _ stands for one character.

    Using `%` instead of `_` would match names of any length, not exactly 4 characters.

    Check that 'John' matches but 'Jonathan' does not.

  4. Combine wildcards for complex patterns

    To find names containing 'son' anywhere, use WHERE name LIKE '%son%'. This matches 'Johnson', 'Anderson', 'sonya'.

    Patterns are case-sensitive in some databases. If you get no results, try `WHERE LOWER(name) LIKE '%son%'`.

    Verify that 'son' appears in the middle of the returned names.

  5. Handle special characters (optional)

    If you need to match a literal % or _, escape them. In SQLite and PostgreSQL, use LIKE '100\%' ESCAPE '\'. MySQL uses LIKE '100%' by default – no escape needed because % at the end is literal if not followed by anything? Actually, MySQL treats % as wildcard always; use LIKE '100%' with ESCAPE if needed. For this lesson, assume you rarely need it.

    Forgetting to escape can cause unexpected matches.

    Test with a row containing `100%` to confirm only that row matches.

Checklist
0/4

Writing a LIKE pattern – field checklist

Keep this list open while you write your LIKE queries.

Exercise2 checks
sql

The table customers has columns id, name, email. Write a query that returns the names and emails of customers whose email domain is gmail.com. Use the LIKE operator with the % wildcard. Your query should return exactly two rows: Alice and Charlie.

Tables you can query

  • customers (id, name, email)
starter code3 lines
SELECT name, email
FROM customers
WHERE email LIKE '%@gmail.com';
  • LIKE compares a column to a pattern using two wildcards: % (any sequence) and _ (exactly one character).
  • Patterns are case-sensitive in some databases; use LOWER() to force case-insensitive matching.
  • Use % at the beginning, end, or both to match substrings anywhere in the value.
  • Use _ to match a fixed number of characters, e.g., 'J___' matches exactly four-letter names starting with J.
  • Escape literal % or _ with an ESCAPE clause if your database requires it.
Check yourself

Test your understanding of LIKE patterns.

1Put in orderChecked instantly

Arrange the steps to write a correct LIKE query that finds products whose name contains 'widget'.

  • AWrite SELECT * FROM products
  • BAdd WHERE name LIKE '%widget%'
  • CIdentify the column and pattern
  • DRun the query and verify results
2Short answerMarked by AI

Explain why a LIKE search for 'Smith' might not find a row containing 'SMITH' in a case-sensitive database, and what you can do to fix it.

Graded on Identifies the cause of the mismatch · Provides a correct fix · Clarity and completeness

3Multiple choiceChecked instantly

Which LIKE pattern would find names that start with 'A' and end with 'n' (e.g., 'Aaron', 'Adrian')?

  • A'A%n'
  • B'%A%n'
  • C'A_n'
  • D'A%'
Go deeper
  • YouTube

    How to Search for Exact word match FROM String using LIKE

    Webslesson·3:01·en

    Open

Watch this short video for a visual walkthrough of LIKE with % and _.

Lesson 16

Sorting with ORDER BY

You will be able to sort results by one or more columns, ascending and descending, using ORDER BY. Covers positional and named references, and the fact that order is never guaranteed without it. Prevents relying on insertion order.

20 min3 assessments

In a spreadsheet, the rows you see are in the order they were entered or sorted. A SQL table is different: it's an unordered set of rows. The database is free to return rows in any order it likes — and it will change that order depending on the query plan, the indexes, or even the day of the week. Your only guarantee is the ORDER BY clause.

Without ORDER BY, the order is undefined. The same query run twice might return rows in a different order. This is not a bug; it's how relational databases work. If you need a specific order, you must say so.

You can sort by one column, multiple columns, in ascending (ASC, the default) or descending (DESC) order. You can also refer to a column by its position in the SELECT list (e.g., ORDER BY 2 means the second column). This is handy but risky: if you change the SELECT list, the sort changes too.

Steps
0/5

Write a query that returns products sorted by price, then by name where prices are equal.

  1. Write the SELECT statement

    SELECT product_id, name, price FROM products

    Don't forget the comma between columns.

    Run the query. It should return rows in some order (probably insertion order, but not guaranteed).

  2. Add ORDER BY with one column

    SELECT product_id, name, price FROM products ORDER BY price

    If you omit `ASC`, it's still ascending. That's usually what you want.

    Prices are now sorted from lowest to highest.

  3. Switch to descending order

    SELECT product_id, name, price FROM products ORDER BY price DESC

    `DESC` applies only to the column it follows. If you have multiple columns, each needs its own `DESC`.

    Prices are now sorted from highest to lowest.

  4. Add a secondary sort column

    SELECT product_id, name, price FROM products ORDER BY price DESC, name

    The order of columns in `ORDER BY` matters. The first column is the primary sort. If two rows have the same price, they are sorted by the second column (name, ascending).

    Rows with the same price are now sorted alphabetically by name.

  5. Use a positional reference

    SELECT product_id, name, price FROM products ORDER BY 2

    Positional references are fragile. If you later change the SELECT list to `SELECT name, price, product_id`, then `ORDER BY 2` now sorts by price instead of name.

    Rows are sorted alphabetically by name (the second column in the SELECT list).

Checklist
0/5

ORDER BY checklist

Keep this checklist open when you write ORDER BY queries.

Exercise3 checks
sql

Write an ORDER BY clause that sorts the products by price ascending, and for products with the same price, by stock descending. Use the column names (not positional references).

Expected order:

  • Doodad (price 5, stock 300)
  • Thingamajig (price 10, stock 200)
  • Widget (price 10, stock 100)
  • Gadget (price 25, stock 50)

Tables you can query

  • products (product_id, name, price, stock)
starter code3 lines
SELECT product_id, name, price, stock
FROM products
ORDER BY
Check yourself

Test your understanding of ORDER BY.

1Put in orderChecked instantly

Arrange the steps for writing a query that sorts products by price descending, then by name ascending. Put them in the correct order:

  • AAdd ORDER BY price DESC, name
  • BWrite SELECT product_id, name, price
  • CChoose the table with FROM products
  • DRun the query and verify the order
2Short answerMarked by AI

A colleague runs a query: SELECT * FROM orders and gets 10 rows in a certain order. They write a script that processes the rows in that order, assuming it will always be the same. Explain why this assumption is dangerous, and what they should do instead.

Graded on Identifies that ORDER BY is missing · Explains why order can change · Suggests the correct fix

Lesson 17

Single-column sort

ORDER BY on one column, ascending by default. Use DESC for descending order.

10 min3 assessments

In a spreadsheet, you click the column header to sort. In SQL, you write ORDER BY. Without it, rows come back in whatever order the database feels like — often the order they were inserted, but never guaranteed.

ORDER BY goes at the end of your query, after WHERE (if any) and before LIMIT. You name the column you want sorted, and the database arranges the rows by that column's values.

sql3 lines
SELECT name, price
FROM products
ORDER BY price;

This returns the cheapest product first. That's ascending order — ASC is the default. To get the most expensive first, add DESC:

sql1 line
ORDER BY price DESC;

That's it. One column, one direction. You already know the idea from spreadsheets; now you just type it.

Steps
0/4

You have a table called employees with columns last_name, salary, hire_date. You want to list everyone from highest to lowest salary.

  1. Write the base SELECT

    Start with SELECT last_name, salary FROM employees; and run it. You'll see rows in no particular order.

    Don't add ORDER BY yet — see the unsorted output first so you can compare.

    You see all rows, but the order looks random.

  2. Add ORDER BY on the salary column

    Append ORDER BY salary to the query. The full query: SELECT last_name, salary FROM employees ORDER BY salary;

    Put ORDER BY after WHERE, not before. If you have a WHERE clause, ORDER BY must come after it.

    Now the lowest salary appears first.

  3. Reverse the order with DESC

    Change to ORDER BY salary DESC and run again.

    Don't forget the space between the column name and DESC. `salaryDESC` is an error.

    The highest salary appears first.

  4. Verify the sort

    Check the first row and the last row. Are they what you expect? If you have ties (same salary), the order among those ties is undefined unless you add a second column — that's for the next lesson.

    If you see the same order as before, double-check you added DESC.

    First row has the highest salary, last row the lowest.

Checklist
0/4

Single-column sort checklist

Keep this open while you write your queries.

Exercise1 check
sql

Write a query that selects name and price from the products table, sorted by price from highest to lowest.

Tables you can query

  • products (id, name, price, category)
starter code3 lines
SELECT name, price
FROM products
ORDER BY
Check yourself

Check your understanding of single-column sort.

1Put in orderChecked instantly

Arrange these query clauses in the correct order for a query that filters rows and then sorts them.

  • AWHERE
  • BORDER BY
  • CSELECT ... FROM
  • DLIMIT
2Short answerMarked by AI

You run SELECT * FROM orders; and get rows in the order they were inserted. Is this guaranteed to always be the case? Explain why or why not in 2–4 sentences.

Graded on Identifies that ORDER BY is required for guaranteed order · Explains why insertion order can change

Lesson 18

Multi-column sort

Sort by multiple columns; the order of columns in ORDER BY matters. The first column is the primary sort.

10 min3 assessments

Multi-column sort in action

Consider an employees table:

namedepartmentsalary
AliceSales50000
BobSales60000
CarolEngineering70000
DaveEngineering55000
EveSales45000

To sort by department (A-Z) and within each department by salary (highest first):

sql3 lines
SELECT name, department, salary
FROM employees
ORDER BY department, salary DESC;

Result:

namedepartmentsalary
CarolEngineering70000
DaveEngineering55000
BobSales60000
AliceSales50000
EveSales45000

Notice: Engineering comes before Sales (primary sort). Within Engineering, Carol (70000) comes before Dave (55000) because of salary DESC. The same pattern repeats for Sales. Swapping the columns to ORDER BY salary DESC, department would give a completely different order – try it!

Steps
0/8

You'll write a multi-column ORDER BY step by step. Each step builds on the last.

  1. Start your SELECT

    Write SELECT column1, column2, ... FROM table as usual.

    Make sure you include all columns you want to see in the result.

    The query runs and returns rows (even if unsorted).

  2. Add ORDER BY

    Type ORDER BY after the FROM clause.

    Don't put a comma before ORDER BY; it's a separate clause.

    No syntax error.

  3. Choose the primary sort column

    Write the first column name (or its position) right after ORDER BY. This is the primary sort.

    If you use a column position, count from 1 in the SELECT list.

    The column exists in the table or SELECT list.

  4. Set direction for the primary column

    Add ASC (ascending, default) or DESC (descending) after the column name.

    If you omit, ASC is assumed. Be explicit if clarity matters.

    The direction keyword is spelled correctly.

  5. Add a comma and the next column

    Type a comma, then the second column name (or position). This is the secondary sort.

    A missing comma will cause a syntax error.

    The comma is there, and the column name is valid.

  6. Set direction for the secondary column

    Add ASC or DESC for the second column. It can be different from the first.

    DESC does NOT carry over from the previous column; each column needs its own direction.

    The direction is what you intended.

  7. Add more columns (optional)

    Repeat steps 5–6 for any additional sort columns.

    Each extra column adds a level of sorting; don't overcomplicate.

    The ORDER BY list has all the columns you need.

  8. Run the query

    Execute the query and examine the result.

    If the order looks wrong, check the column order in ORDER BY – the first column is the most significant.

    Rows are sorted as expected: primary column groups together, secondary column sorts within those groups.

Checklist
0/7

Multi-column sort checklist

Keep this list open while writing your queries.

  • YouTube

    SQL ORDER BY With Multiple Columns

    Watch this short video to see a live demonstration of multi-column sorting in SQL, reinforcing the order-of-columns concept.

    Jamie King·3:50·en

    Open
  • ORDER BY can list multiple columns separated by commas.
  • The first column in ORDER BY is the primary sort; subsequent columns are secondary, tertiary, etc.
  • Each column can have its own ASC or DESC direction; default is ASC.
  • The order of columns in ORDER BY determines the grouping of rows – swapping columns changes the result.
  • Column positions (1,2,3) can be used instead of names, but names are clearer and more maintainable.
  • DESC applies only to the column it immediately follows, not to all subsequent columns.
  • Mixing ASC and DESC is allowed and common for different levels of sorting.
Check yourself

Test your understanding of multi-column sorting.

1Short answerMarked by AI

Explain why the order of columns in ORDER BY matters. Give an example where swapping the columns changes the result.

Graded on Explains primary vs secondary sort · Provides correct example

2CodeChecked instantly

You have a products table with columns product, category, and price. The rows are:

productcategoryprice
LaptopElectronics1200
MouseElectronics25
ChairFurniture80
DeskFurniture300
KeyboardElectronics45

Complete the SQL query so it returns all five rows sorted by category from A to Z, and within each category by price from highest to lowest. Leave the SELECT list unchanged; only fill in the ORDER BY clause.

sql3 lines
SELECT product, category, price
FROM products
ORDER BY ;
3Short answerMarked by AI

You need a report of products grouped by category (A–Z), and within each category by price from highest to lowest. A teammate runs this query:

sql3 lines
SELECT product, category, price
FROM products
ORDER BY price DESC, category;

The output starts with the most expensive product overall, not grouped by category. Explain in 2–4 sentences why this query produces scattered categories, and write the corrected ORDER BY clause.

Graded on Diagnoses why categories are scattered in the output · Writes a corrected ORDER BY clause · Explains why the corrected clause groups the rows

Lesson 19

NULL ordering and positional references

NULLs sort last by default in most systems. You can refer to columns by their position in the SELECT list (e.g., ORDER BY 2).

10 min3 assessments

When you sort query results, NULLs — missing values — behave differently than you might expect. In most databases, NULL is treated as 'unknown', not as zero or an empty string. By default, ORDER BY places NULLs at the end of the result, regardless of whether you sort ascending or descending.

But you can change this with NULLS FIRST or NULLS LAST clauses. You also have a shortcut: instead of typing a column name in ORDER BY, you can use its position in the SELECT list — ORDER BY 2 means "sort by the second column I selected." This is called a positional reference.

Both features exist because real queries get long, and you need precise control over where missing data appears.

You have two ways to tell ORDER BY what to sort by: naming the column or using its position. Each has a clear use case.

Column name

Readability
High — you see the column's purpose
Survives column reorder
Yes — query breaks if column removed
Works with expressions
Yes — you can sort by total * 1.1
Best when...
You're writing a query others will read or maintain

Best for Shared scripts, reports, and any query where clarity matters more than keystrokes

Positional reference

Readability
Low — ORDER BY 3 tells you nothing about what column 3 is
Survives column reorder
No — adding a column to SELECT shifts all positions
Works with expressions
No — only works on columns that appear in SELECT, not on computed values
Best when...
You're prototyping, or the SELECT list is short and stable

Best for Quick ad-hoc queries where you're the only person who will see the SQL

NULLs at the end vs. NULLs first

Consider a products table with a discount column that is NULL for items not on sale.

sql3 lines
SELECT name, discount
FROM products
ORDER BY discount;

Result: | name | discount | |------|----------| | Hat | 5.00 | | Socks| 10.00 | | Belt | NULL | | Shoes| NULL |

NULLs come last by default. To bring them to the top:

sql3 lines
SELECT name, discount
FROM products
ORDER BY discount NULLS FIRST;

Result: | name | discount | |------|----------| | Belt | NULL | | Shoes| NULL | | Hat | 5.00 | | Socks| 10.00 |

Now try a positional reference — sort by the second column (discount) descending, with NULLs last:

sql3 lines
SELECT name, discount
FROM products
ORDER BY 2 DESC NULLS LAST;

Result: | name | discount | |------|----------| | Hat | 5.00 | | Socks| 10.00 | | Belt | NULL | | Shoes| NULL |

Notice: NULLs are at the end even though DESC would normally put the highest values first. NULLS LAST overrides the default.

Common misconception

“NULL is treated as 0 or an empty string when sorting, so it should appear first in ascending order.”

In spreadsheets, empty cells are treated as zero or blank, and sorting A→Z puts blanks at the top. It's natural to expect the same behavior in SQL.

What is actually true

NULL in SQL means 'unknown value' — it is not less than or greater than any actual value. Most databases treat NULL as higher than all non-NULL values by default, so it sorts last in ascending order and also last in descending order (unless you use NULLS FIRST/LAST).

Exercise1 check
sql

Write a query that sorts employees by salary, but places employees with unknown salaries (NULL) at the top of the result.

Tables you can query

  • employees (id, name, salary)
starter code3 lines
SELECT name, salary
FROM employees
ORDER BY salary;
Check yourself

Check your understanding of NULL ordering and positional references.

1Select all that applyChecked instantly

Which of the following statements about NULL ordering in SQL are true? Select all that apply.

  • AIn most databases, NULLs sort after all non-NULL values by default.
  • BNULLS FIRST can be used to bring NULLs to the beginning of the result.
  • CNULL is treated as 0 when sorting ascending.
  • DNULLS LAST is the default behavior in PostgreSQL and MySQL.
  • EYou can use NULLS FIRST/LAST with both ascending and descending sorts.
2Short answerMarked by AI

You have a query: SELECT product_name, price FROM inventory ORDER BY 2 DESC;. A colleague adds a new column to the SELECT list: SELECT product_id, product_name, price FROM inventory ORDER BY 2 DESC;. Explain what the query will now sort by and why this is a problem.

Graded on Identifies the new sort column · Explains the risk · Clarity and precision

Lesson 20

Aggregation and GROUP BY: your pivot table replacement

You will be able to write GROUP BY queries that count, sum, average, and find minimum and maximum values per group. Covers COUNT, SUM, AVG, MIN, MAX, and the rule that every non-aggregated column must be in the GROUP BY clause. Prevents mixing aggregated and non-aggregated columns incorrectly.

40 min4 assessments

In your spreadsheet, comparing regions by sales took a drag: Region into Rows, Amount into Values, Sum as the summary, and the grid collapsed the raw rows into one row per region. SQL does the same collapse, but you write it out and the database runs it in a fixed order. You already know how to sort the result — ORDER BY happens last and is the easy part. The part that takes thinking is the collapse itself.

The words map onto the pivot table exactly. The column you dragged into Rows becomes the GROUP BY column. The summary you picked becomes an aggregate function: SUM, COUNT, AVG, MIN or MAX. A report filter that excluded rows before the pivot became WHERE, and it still runs before any grouping. What is different is that a spreadsheet lets you drag almost anything anywhere and silently guesses what you meant. SQL refuses to guess: if a column appears in SELECT but is not inside an aggregate, the database will not decide which value to show — you must name it in GROUP BY. That rule is the whole difference between "works" and "this query has an error".

  • GROUP BY collapses every row that shares the same value in the listed column(s) into a single output row.
  • Every column in SELECT that is not wrapped in an aggregate must be listed in GROUP BY — otherwise the database errors.
  • WHERE runs before GROUP BY: it removes rows, so it can never filter a group or use an aggregate.
  • ORDER BY runs after grouping, so it can sort by the aggregate result, as in ORDER BY SUM(amount) DESC.
  • COUNT(*), SUM, AVG, MIN and MAX each produce exactly one value per group, and you can combine them in one SELECT.
  • A group with no surviving rows returns no output row at all — never a row showing 0.

The pipeline a GROUP BY query runs through, and the row count at each stage.

Steps
0/7

You manage a small sales team, and the boss wants totals by region. You'll write one query, from the vague question to a finished GROUP BY, against this table — run the queries as you go.

idregionsalespersonamount
1NorthAda120
2NorthAda80
3SouthGrace150
4NorthAlan200
5SouthGrace90
6EastEdith175
7EastEdith60
8SouthGrace40
  1. Name the group column and the value column first

    Decide which column becomes the group and which becomes the value. Here the group is region and the value is amount. In spreadsheet terms you just chose the Rows field and the Values field. Write both down before touching the keyboard.

    Guessing the group column produces a query that runs and quietly answers the wrong question — no error message will save you.

    You can say it out loud: "one output row per region, and inside each output row the sum of amount."

  2. Run the aggregate by itself

    Run SELECT SUM(amount) FROM sales; — it returns 915. You are proving the aggregate works before you split it into groups.

    One number comes back: 915, the total of all eight amounts.

  3. Add the group column and GROUP BY

    Put region into SELECT and name it in GROUP BY:

    SELECT region, SUM(amount) FROM sales GROUP BY region;

    Eight rows collapse into three: North 400, South 280, East 235.

    Drop GROUP BY and the database errors: column region must appear in the GROUP BY clause or be used in an aggregate function. That is the non-aggregated column rule refusing to guess which region's value to show.

    The number of returned rows equals the number of distinct regions — three. And 400 + 280 + 235 = 915, matching step 2.

  4. Add the other aggregates to the same query

    Replace SUM(amount) with the summaries you want, one aggregate per column:

    SELECT region, COUNT(*) AS orders, AVG(amount) AS avg_amount, MIN(amount) AS smallest, MAX(amount) AS largest FROM sales GROUP BY region;

    For North: 3 orders, average 133.3, smallest 80, largest 200.

    Keep every aggregate inside SELECT. A WHERE clause may not use an aggregate — filtering groups is a separate step that comes later in this course.

    COUNT(*) matches the raw rows: North 3, South 3, East 2.

  5. Filter rows with WHERE, placed before GROUP BY

    The boss changes the question: totals by region, orders of 100 or more only. Write the filter before GROUP BY:

    SELECT region, SUM(amount) FROM sales WHERE amount >= 100 GROUP BY region;

    Now North 320, South 150, East 175.

    WHERE after GROUP BY is a syntax error; an aggregate inside WHERE is rejected too, because WHERE runs before any grouping exists.

    The totals now add to 645, not 915 — four rows passed the filter and the rest never formed a group.

  6. Sort the groups with ORDER BY, after GROUP BY

    Finish the answer by sorting on the aggregate. Give it an alias and use it:

    SELECT region, SUM(amount) AS total FROM sales WHERE amount >= 100 GROUP BY region ORDER BY total DESC;

    Returns North 320, East 175, South 150.

    ORDER BY runs after the groups are built, so it may reference the alias total — but the same alias would be illegal in WHERE. Position in the sentence is position in the pipeline.

    The first row holds the highest total: 320 before 175 before 150, not an alphabetical region order.

  7. Verify one group by hand

    Pick a group — East. Its raw amounts are 175 and 60. With the filter amount >= 100, only 175 survives, which is what the step-5 query shows. Re-run the query without the filter and confirm East shows 235, both raw rows summed.

    If your hand sum disagrees with the query, the filter or the grouping is wrong. Fix step 5 before building on it.

    The query result and your independent sum agree.

Checklist
0/8

While you write your own GROUP BY query

Keep this next to your editor and tick each item only after you have verified it, not because you intend to.

Exercise2 checks
sql

Complete the query. It must return one row per region with that region's total sales, counting only rows where amount is at least 100, and it must be ordered from the highest total to the lowest.

The three clauses you need are WHERE, GROUP BY and ORDER BY — you already wrote each of them in the walkthrough.

Expected output:

regiontotal
North320
East175
South150

Tables you can query

  • sales (id, region, salesperson, amount)
starter code5 lines
SELECT region, SUM(amount) AS total
FROM sales
-- TODO 1: add a WHERE clause to keep only amount >= 100
-- TODO 2: add GROUP BY so each region appears once
-- TODO 3: add ORDER BY so the largest total comes first
Check yourself

Three checks after the walkthrough — you can answer these without a database.

1Multiple choiceChecked instantly

You run this query and the database refuses it:

SELECT department, COUNT(*), salary FROM employees GROUP BY department;

Why does it error?

  • Asalary is not inside an aggregate, so it must be added to GROUP BY — every non-aggregated column in SELECT has to appear in the group list.
  • BCOUNT(*) cannot be combined with any other column inside a single SELECT.
  • CThe fix is to remove GROUP BY and run the query without it.
  • DThe database resolves the ambiguity by showing the first salary it reads for each department.
2Put in orderChecked instantly

The database executes the clauses of a GROUP BY query in a fixed order. Put these four stages in the order they actually run.

  • AWHERE filters the raw rows
  • BORDER BY sorts the final result rows
  • CGROUP BY forms groups from the filtered rows
  • DAggregates compute one value per group
3Short answerMarked by AI

You run this query:

SELECT region, COUNT(*) AS orders FROM sales WHERE region = 'Mars' GROUP BY region;

The sales table has no rows where region is 'Mars'. What does the query return, and why does it not return a row with orders = 0?

Graded on States whether the result is zero rows or a row with 0 · Explains the mechanism in terms of WHERE running before GROUP BY · Connects the outcome to the general rule about vanished groups

Lesson 21

Basic aggregate functions

Use COUNT, SUM, AVG, MIN, MAX to compute summary values. COUNT(*) counts rows, COUNT(column) counts non-NULL values.

15 min3 assessments

You can already group rows with GROUP BY — that is your pivot table's row labels. Now you need the functions that fill the values section.

The five basic aggregate functions are COUNT, SUM, AVG, MIN, and MAX. They take a column of values and collapse it into a single summary number.

COUNT is the most common. COUNT(*) counts every row in the table, including rows where every single column is NULL. COUNT(column_name) counts only the rows where that specific column has a value (is not NULL).

SUM adds up the numbers in a column. AVG calculates the average — it divides the sum by the number of non-NULL values. This is the biggest trap: AVG(col) ignores NULLs, it does not treat them as zero. The average of 10, 20, and NULL is 15, not 10.

MIN and MAX find the smallest and largest value. They work on numbers, text (alphabetically), and dates (chronologically).

Master these five functions and you can answer almost any "how many?", "how much?", or "what is the range?" question from your data.

Reference

SQL Aggregate Functions Quick Reference

COUNT(*)

Purpose
Count all rows in a table
NULL Handling
Includes rows with NULLs
Works on
Any data type

COUNT(col)

Purpose
Count non-NULL values in a column
NULL Handling
Ignores NULLs
Works on
Any data type

SUM(col)

Purpose
Total of a numeric column
NULL Handling
Ignores NULLs
Works on
Numbers

AVG(col)

Purpose
Average of a numeric column
NULL Handling
Ignores NULLs. Divides by count of non-NULL rows.
Works on
Numbers

MIN(col)

Purpose
Smallest value in a column
NULL Handling
Ignores NULLs
Works on
Numbers, Text, Dates

MAX(col)

Purpose
Largest value in a column
NULL Handling
Ignores NULLs
Works on
Numbers, Text, Dates

Keep this table open when you write your first aggregate queries. The most common mistake is assuming `AVG` treats NULLs as zero.

Practice
0/7 known
1 / 7
Go deeper
  • YouTube

    Basic Aggregate Functions in SQL (COUNT, SUM, AVG, MAX, and MIN)

    Becoming a Data Scientist·5:44·en

    Open
  • YouTube

    SQL Aggregate Functions | COUNT, SUM, AVG, MAX, MIN | #SQL Course 21

    Data with Baraa·6:38·en

    Open

Watch these videos to see how the five aggregate functions are used in real SQL queries against a sample database.

Check yourself

Practice the difference between the five aggregate functions.

1Multiple choiceChecked instantly

You have a table orders with columns order_id, customer_id, and amount. Every order has a valid amount and customer_id. Which query correctly returns the number of orders placed by each customer?

  • ASELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id;
  • BSELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id;
  • CSELECT customer_id, COUNT(customer_id) FROM orders;
  • DSELECT customer_id, COUNT(amount) FROM orders GROUP BY amount;
2Fill the blanksChecked instantly
3Select all that applyChecked instantly

Your table employees has 10 rows. The salary column has 3 NULL values and 7 valid numbers. Which of the following queries will return the value 7? Select all that apply.

  • ASELECT COUNT(salary) FROM employees;
  • BSELECT COUNT(*) FROM employees WHERE salary IS NOT NULL;
  • CSELECT SUM(salary) FROM employees;
  • DSELECT AVG(salary) FROM employees;
  • ESELECT COUNT(1) FROM employees;

Lesson 22

GROUP BY clause

Group rows that share a common value in one or more columns, then apply aggregates to each group.

15 min3 assessments

You've used a pivot table to sum sales by region — one row per region, one column for the total. In SQL, that's the GROUP BY clause. It takes rows that share a value in one or more columns and collapses them into a single row per group, so you can run aggregate functions like SUM(), COUNT(), or AVG() on each group separately.

Without GROUP BY, an aggregate function like SUM(price) runs on the whole table and returns one number. With GROUP BY category, it runs once per category and returns as many numbers as there are categories. The key rule: every column in the SELECT list that is not inside an aggregate function must appear in the GROUP BY clause. If you break that rule, SQLite (and most databases) will either throw an error or return a wrong value from an arbitrary row.

Steps
0/6

We'll write a GROUP BY query step by step using a table called orders with columns: order_id, customer, category, amount, quantity. We want total sales per category, sorted from highest to lowest.

  1. Decide the grouping columns

    Each group is defined by the unique combination of values in the columns you name. Here, we want one row per category, so the grouping column is category.

    Picking too many columns creates too many groups — you might get a row for every combination instead of the summary you intended.

    Write down the columns that will appear in the SELECT list outside aggregates; they must be here.

  2. Choose the aggregate functions

    We need total sales per category, so we use SUM(amount) as the total. We'll also add COUNT(*) for number of orders in each category.

    Don't forget `COUNT(*)` counts rows, `COUNT(column)` counts non-null values in that column. They are not the same.

  3. Write the SELECT clause

    Write SELECT category, SUM(amount) AS total_sales, COUNT(*) AS order_count FROM orders

    If you include a column like `order_id` without an aggregate, you must also add it to GROUP BY — but that would make each row unique, defeating the purpose.

    Verify that every column in SELECT that is not wrapped in an aggregate (MIN, MAX, SUM, AVG, COUNT) is in the list you wrote in step 1.

  4. Add the GROUP BY clause

    Write GROUP BY category after the FROM clause. This tells SQLite to collapse rows with the same category into one row per category.

    The GROUP BY clause must come after WHERE but before ORDER BY or LIMIT. Putting it in the wrong order causes a syntax error.

    Run the query: `SELECT category, SUM(amount) AS total_sales, COUNT(*) AS order_count FROM orders GROUP BY category;` — you should see one row per category.

  5. Filter groups with HAVING (optional)

    If you want only categories with total sales over $1000, add HAVING total_sales > 1000 after the GROUP BY. HAVING is like WHERE for groups — it filters after aggregation.

    You cannot use HAVING before GROUP BY, and you cannot use WHERE to filter an aggregate — WHERE runs before grouping, so `WHERE SUM(amount) > 1000` is invalid.

    Add `HAVING total_sales > 1000` and run again. Only groups meeting the condition appear.

  6. Order the results

    Add ORDER BY total_sales DESC to show the highest-selling category first.

    You can ORDER BY an alias (like `total_sales`) because ORDER BY is evaluated after SELECT. But this does not work in all databases — SQLite allows it.

    Run the final query and confirm the output is sorted descending by total sales.

Checklist
0/5

GROUP BY Query Checklist

Keep this checklist open when writing your first few GROUP BY queries.

Exercise2 checks
sql

The sales table has columns: region (TEXT), product (TEXT), amount (REAL). Write a query that returns the total amount sold per region, but only for regions where the total exceeds 1000. The result should have two columns: region and total_amount. Order the results by total_amount descending.

Tables you can query

  • sales (region, product, amount)
starter code5 lines
SELECT 
-- TODO
FROM sales
GROUP BY 
;
Check yourself

Test your understanding of the GROUP BY clause.

1Put in orderChecked instantly

Arrange the following steps in the correct order to write a GROUP BY query that returns total sales per category, only for categories with average quantity > 5, sorted by category name.

  • AWrite SELECT list: category, SUM(amount) as total, AVG(quantity) as avg_qty
  • BAdd HAVING avg_qty > 5
  • CAdd GROUP BY category
  • DAdd ORDER BY category
  • EWrite FROM orders
2Short answerMarked by AI

The following query does not work. Explain why, and write the corrected version.

sql1 line
SELECT product, SUM(amount) FROM orders GROUP BY category;

Graded on Identifies the error (product not in GROUP BY and not aggregated) · Provides a correct SQL fix · Explains the reasoning behind the fix

Go deeper

Watch the videos for a visual walkthrough of GROUP BY, and read the Wikipedia article for the formal definition.

Lesson 23

The non-aggregated column rule

Every column in SELECT that is not an aggregate must appear in GROUP BY. This prevents ambiguous results.

10 min3 assessments

The rule in one sentence

When you write a SELECT that uses GROUP BY, every column in the SELECT list that is not inside an aggregate function must also appear in the GROUP BY clause.

You already know the aggregate functions from Aggregation and GROUP BY: your pivot table replacement: COUNT, SUM, AVG, MIN, MAX. Each one collapses many rows into one number. A plain column does not collapse anything — it asks for a value, and when a group has many rows, there are many values to choose from.

sql3 lines
SELECT region, product, SUM(amount)
FROM sales
GROUP BY region;

region is fine: there is one region per group. product is not: a group for North contains both Widget and Gadget. The database cannot know which one you want, so the standard says the result is undefined. Some databases let this run and return a row anyway; that row is not a defined answer, it is whatever the engine happened to read first.

The fix is to make the column unambiguous. Either add it to GROUP BY so each group has one value, or wrap it in an aggregate such as MIN(product) or MAX(product) if any value will do.

Why a non-aggregated column must be in GROUP BY.

Analogy

Think of GROUP BY as sorting a deck of cards into piles by suit. SUM(amount) is like asking for the total value of each pile — one number per pile, no ambiguity. A plain column like product is like asking for "the card" from a pile. Which card? The pile has thirteen. SQL has no rule for which one to hand you, so the answer is whatever the database happens to grab.

Putting product in GROUP BY is the same as splitting each suit pile into one pile per rank: now "the card" is unambiguous because every pile contains identical cards.

Where the analogy breaks down

In a real deck you can peek and pick the top card, and you would get a definite answer every time. SQL does not promise which row it picks for a non-aggregated column, and the same query can return different values on different databases or even different runs. The rule exists because SQL refuses to make that arbitrary choice for you.

The ambiguous product column

Table sales:

regionproductamount
NorthWidget10
NorthGadget20
SouthWidget30
SouthGadget40
NorthWidget5

You want total sales per region. This query is valid:

sql3 lines
SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region;
regiontotal
North35
South70

Now add product without grouping by it:

sql3 lines
SELECT region, product, SUM(amount) AS total
FROM sales
GROUP BY region;

Each region has two products. Which one goes in the row? SQLite will return one — often the first it happens to read — but the standard says the result is undefined. The fix:

sql3 lines
SELECT region, product, SUM(amount) AS total
FROM sales
GROUP BY region, product;
regionproducttotal
NorthWidget15
NorthGadget20
SouthWidget30
SouthGadget40
Common misconception

“If I put a column in SELECT that isn't in GROUP BY, the database will just show the first value in each group, like a pivot table does.”

Spreadsheet pivot tables let you drop any field into the Values area and they show something — often a first or last value — and some databases like SQLite and MySQL return a row instead of an error.

What is actually true

The SQL standard says the value is undefined. The database is free to return any value from the group, so the same query can give different answers on another database or after a data change. If you want a specific value, put the column in GROUP BY or wrap it in an aggregate like MIN or MAX.

Exercise1 check
sql

The query below groups by region only, so product is a non-aggregated column with several values per group. Fix the GROUP BY clause so the query returns total sales for each region and product combination. Keep the alias total.

Tables you can query

  • sales (id, region, product, amount)
starter code3 lines
SELECT region, product, SUM(amount) AS total
FROM sales
GROUP BY region;
Check yourself

Check yourself.

1Multiple choiceChecked instantly

Which query follows the non-aggregated column rule?

  • ASELECT region, product, SUM(amount) FROM sales GROUP BY region, product;
  • BSELECT region, product, SUM(amount) FROM sales GROUP BY region;
  • CSELECT region, SUM(amount) FROM sales GROUP BY product;
  • DSELECT product, SUM(amount) FROM sales GROUP BY region;
2Short answerMarked by AI

A teammate shows you this query: SELECT department, employee, COUNT(*) FROM staff GROUP BY department; It runs without an error on their database, but the employee column shows a name you didn't expect. Explain what is happening and what the query should be.

Graded on Identifies the column that breaks the rule · Prescribes a correct fix · States the consequence

Lesson 24

HAVING: filtering after aggregation

You will be able to filter groups with HAVING and explain why it runs after WHERE. Covers the difference between filtering rows and filtering groups. Prevents putting an aggregate in a WHERE clause.

20 min4 assessments

You already know how to group rows with GROUP BY and calculate totals with SUM, COUNT, AVG. But what if you only want groups that meet a condition on that total — say, regions with over $1,000 in sales? Putting SUM(amount) > 1000 in a WHERE clause gives an error. Why?

WHERE runs before GROUP BY. It examines each raw row and decides whether to keep it. At that point, the database hasn't grouped anything yet, so aggregate functions like SUM have nothing to work on.

HAVING is the clause that runs after GROUP BY. It sees the finished groups and their aggregates, so it can filter them. Think of it as a WHERE for groups: SELECT region, SUM(amount) FROM sales GROUP BY region HAVING SUM(amount) > 1000; works because by the time HAVING executes, each group's total is already computed.

This distinction is the core of the WHERE vs. HAVING decision. WHERE removes rows before grouping; HAVING removes groups after grouping.

When deciding between WHERE and HAVING, these axes matter:

WHERE

Execution order
Before GROUP BY
Filters
Individual rows
Can use aggregate functions?
No
Typical use case
Filter rows based on column values

Best for Excluding irrelevant rows early, before any grouping happens

HAVING

Execution order
After GROUP BY
Filters
Groups (results of aggregation)
Can use aggregate functions?
Yes
Typical use case
Filter groups based on aggregate results

Best for Keeping only groups that satisfy a condition on their summary values

Finding big spenders

You have an orders table:

customer_idproductamount
1Widget A200
1Widget B350
2Widget A100
2Widget C50
3Widget D600

Goal: Find customers who have spent more than $500 total.

Wrong attempt:

sql4 lines
SELECT customer_id, SUM(amount)
FROM orders
WHERE SUM(amount) > 500
GROUP BY customer_id;

→ Error: aggregate functions not allowed in WHERE.

Correct query:

sql4 lines
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 500;

Result: | customer_id | total | |-------------|-------| | 1 | 550 | | 3 | 600 |

Customer 2 spent $150, so that group is dropped by HAVING.

Common misconception

“I can put an aggregate in WHERE because WHERE accepts conditions, and `SUM(amount) > 500` looks like a condition.”

WHERE is the go‑to place for filtering, and the condition `SUM(amount) > 500` is a perfectly valid boolean expression in many programming contexts.

What is actually true

SQL evaluates WHERE row by row before any grouping occurs. At that stage, SUM has not been computed — it would have to look at all rows, which contradicts the row‑by‑row evaluation. HAVING is evaluated after grouping, when each group's aggregate is available, so it is the correct clause for filter conditions that involve aggregates.

Exercise1 check
sql

Write a query that returns only regions where the total sales amount is greater than 1000. Include both the region name and the total in the result.

Tables you can query

  • sales (region, amount)
starter code4 lines
SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region
-- TODO: add HAVING clause
Check yourself

Check your understanding of HAVING vs. WHERE.

1Select all that applyChecked instantly

Which of the following statements about HAVING are true? (Select all that apply.)

  • AHAVING can use aggregate functions.
  • BHAVING filters rows before grouping.
  • CHAVING runs after GROUP BY.
  • DHAVING can reference any column from the SELECT list, even if it is not in GROUP BY.
2Short answerMarked by AI

Explain why the following query fails: SELECT department, AVG(salary) FROM employees WHERE AVG(salary) > 50000 GROUP BY department;

Graded on Identifies the execution order issue · Suggests correct fix

3Multiple choiceChecked instantly

You want to find product categories that have more than 10 orders. Which clause should you use?

  • AWHERE COUNT(*) > 10
  • BHAVING COUNT(*) > 10
  • CWHERE COUNT(orders) > 10
  • DGROUP BY COUNT(*) > 10

Lesson 25

WHERE filters rows before grouping

WHERE runs before GROUP BY, so it cannot reference aggregates. It filters raw rows based on column values.

10 min3 assessments

You already know that HAVING filters groups after aggregation. But what if you need to exclude certain rows before any grouping happens? That's the job of WHERE.

WHERE runs before GROUP BY. It looks at each raw row in the table and decides whether to keep it. Because grouping hasn't happened yet, WHERE cannot see any aggregate value — SUM, COUNT, AVG, etc. — those simply don't exist at that point.

Consider a table orders with columns region, sales, and date.

sql4 lines
SELECT region, SUM(sales)
FROM orders
WHERE sales > 100
GROUP BY region;

This works: WHERE sales > 100 filters out any row where the sale amount is 100 or less. Only rows with sales > 100 reach GROUP BY. The result shows total sales per region for big transactions only.

Now try to use an aggregate in WHERE:

sql4 lines
SELECT region, SUM(sales)
FROM orders
WHERE SUM(sales) > 1000
GROUP BY region;

This fails with an error like 'invalid use of aggregate function' or 'aggregate functions not allowed in WHERE'. The database tells you exactly why: SUM(sales) has no value until after grouping, but WHERE runs before grouping.

The correct way to filter groups is HAVING:

sql4 lines
SELECT region, SUM(sales)
FROM orders
GROUP BY region
HAVING SUM(sales) > 1000;

WHERE and HAVING are not interchangeable — they operate at different stages. Use WHERE to filter rows, HAVING to filter groups.

Logical query processing order in SQL. WHERE filters rows before GROUP BY creates groups.

Analogy

Imagine you have a large basket of mixed fruit — apples, oranges, bananas, some ripe, some not. You want to know how many ripe apples you have.

WHERE is like first picking out only the ripe fruit from the basket. You ignore everything unripe. Then you group the remaining fruit by type (apples, oranges, etc.) and count each group.

If you tried to say "only keep groups where the count is greater than 5" during the picking step, you couldn't — you haven't counted yet. That's exactly what HAVING does: it filters after counting.

So WHERE = pick ripe fruit first; HAVING = keep only groups with enough fruit after counting.

Where the analogy breaks down

The analogy suggests you can only filter at one stage, but SQL allows both `WHERE` and `HAVING` in the same query — they serve different purposes and can be combined. Also, `WHERE` can filter on any column, not just a simple condition like ripeness.

Common misconception

“I can put `SUM(sales) > 1000` in the `WHERE` clause because it's just another condition.”

`WHERE` accepts conditions, and `SUM(sales)` is a function that returns a number, so it looks like a valid condition. Spreadsheet users often filter on calculated fields without thinking about order.

What is actually true

WHERE operates on individual rows before any grouping or aggregation. At that point, SUM(sales) has no value — the database hasn't added anything up yet. The correct clause for conditions on aggregates is HAVING, which runs after GROUP BY.

  • WHERE filters rows before GROUP BY runs, so it cannot reference aggregate functions like SUM, COUNT, or AVG.
  • An aggregate function in WHERE causes a syntax error because the value doesn't exist yet.
  • Use WHERE to exclude individual rows based on column values before grouping.
  • Use HAVING to filter groups after aggregation — it is the counterpart to WHERE for grouped results.
  • WHERE and HAVING can appear in the same query: WHERE filters rows, HAVING filters groups.
  • The logical order is: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
Check yourself

Check your understanding of WHERE vs HAVING.

1Short answerMarked by AI

A colleague writes this query and gets an error: SELECT category, AVG(price) FROM products WHERE AVG(price) > 50 GROUP BY category; Explain why it fails and rewrite it correctly.

Graded on Identifies the reason for the error · Provides a correct corrected query · Explains the role of HAVING

2EssayMarked by AI

You are reviewing a query written by a teammate: SELECT region, SUM(revenue) FROM sales WHERE SUM(revenue) > 100000 GROUP BY region; The query fails. Explain the logical processing order that causes the error, then describe how you would fix it. Also discuss a scenario where using both WHERE and HAVING in the same query would be appropriate, and why that combination is more efficient than filtering everything in HAVING alone.

Graded on Explains the logical processing order and the error · Provides a correct fix and justifies it · Describes a scenario using both WHERE and HAVING and explains efficiency

Check yourself
1Multiple choiceChecked instantly

Which of the following queries will run without error?

  • ASELECT department, COUNT(*) FROM employees WHERE COUNT(*) > 5 GROUP BY department;
  • BSELECT department, COUNT(*) FROM employees GROUP BY department HAVING COUNT(*) > 5;
  • CSELECT department, COUNT(*) FROM employees WHERE salary > 50000 HAVING COUNT(*) > 5 GROUP BY department;
  • DSELECT department, COUNT(*) FROM employees WHERE salary > 50000 GROUP BY department HAVING COUNT(*) > 5;

Lesson 26

HAVING filters groups

HAVING runs after GROUP BY, so it can reference aggregate functions. Use it to filter groups based on aggregate results.

10 min3 assessments
Steps
0/4

You've grouped your sales by region and counted orders. Now you want only regions with more than 100 orders. That's what HAVING does. Follow these steps to add a HAVING clause to your query.

  1. Write the GROUP BY query first

    Start with a query that groups rows and computes the aggregates you need. For example: SELECT region, COUNT(*) AS order_count FROM orders GROUP BY region;. Run it to see every group and its aggregate value.

    If you try to add HAVING before GROUP BY, the query will fail with a syntax error.

    The query runs and returns one row per group with the aggregate value.

  2. Decide the condition on the aggregate

    Write the condition that must be true for a group to appear in the result. For example: COUNT(*) > 100. The condition must use an aggregate function, not a plain column.

    Do not use a column alias from SELECT in HAVING unless your database supports it — it's safer to repeat the full aggregate expression.

    The condition references an aggregate function (like COUNT, SUM, AVG, MAX, MIN).

  3. Add HAVING after GROUP BY

    Append HAVING condition immediately after the GROUP BY clause. Example: SELECT region, COUNT(*) AS order_count FROM orders GROUP BY region HAVING COUNT(*) > 100;.

    HAVING cannot reference columns that are not in GROUP BY or wrapped in an aggregate function, unless they appear in GROUP BY.

    The query runs without error and returns only groups that satisfy the condition.

  4. Optionally add ORDER BY

    If you want the filtered groups sorted, add ORDER BY after HAVING. Example: ... HAVING COUNT(*) > 100 ORDER BY order_count DESC;.

    ORDER BY must come after HAVING, not before it.

    The final result is sorted as expected.

Checklist
0/5

HAVING clause checklist

Keep this checklist open while you write your HAVING queries.

Filtering product categories with average price > 50

Assume a table products with columns category and price. You want categories where the average price is greater than 50.

Data: | category | price | |-------------|-------| | Electronics | 120 | | Electronics | 80 | | Stationery | 15 | | Stationery | 10 | | Clothing | 40 | | Clothing | 60 |

Query:

sql4 lines
SELECT category, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING AVG(price) > 50;

Result: | category | avg_price | |-------------|-----------| | Electronics | 100 | | Clothing | 50 |

Note: Clothing has an average of exactly 50, which is not greater than 50, so it is excluded. Only Electronics (avg 100) qualifies.

  • HAVING filters groups after aggregation, unlike WHERE which filters rows before aggregation.
  • HAVING can reference aggregate functions (COUNT, SUM, AVG, etc.); WHERE cannot.
  • HAVING must appear after GROUP BY and before ORDER BY in the query.
  • You can use multiple conditions in HAVING with AND/OR.
  • HAVING conditions often use the same aggregate expressions as in SELECT, but you can use different ones.
  • HAVING is evaluated per group, so it can only reference columns in GROUP BY or aggregate functions.
Exercise1 check
sql

Write a HAVING clause that filters the groups to show only regions that have more than 2 orders. The query already groups by region and counts orders and sums total. Your task is to add the HAVING clause after the GROUP BY line. Remember: HAVING uses aggregate functions.

Tables you can query

  • orders (id, customer, total, region)
starter code4 lines
SELECT region, COUNT(*) AS order_count, SUM(total) AS total_sales
FROM orders
GROUP BY region
-- Add HAVING clause to show only regions with more than 2 orders
Check yourself

Check your understanding of HAVING and query execution order.

1Put in orderChecked instantly

Arrange these SQL clauses in the order they are evaluated by the database engine (from first to last).

  • AWHERE
  • BGROUP BY
  • CHAVING
  • DSELECT
  • EORDER BY
2Short answerMarked by AI

Consider the query: SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department HAVING AVG(salary) > 50000;. Why can't you replace HAVING with WHERE in this query? Explain in 2-4 sentences.

Graded on Identifies that WHERE filters rows before aggregation · Uses correct terminology (aggregate function, GROUP BY)

Lesson 27

Cannot mix aggregates in WHERE

Recognize the error of putting an aggregate in WHERE and rewrite it using HAVING. This is a common mistake.

10 min5 assessments
Decide

Maria works in HR and needs a report: departments where the average salary exceeds $50,000. She writes this query in her company's database tool:

sql4 lines
SELECT department, AVG(salary)
FROM employees
GROUP BY department
WHERE AVG(salary) > 50000;

When she runs it, she sees this error message: `aggregate functions are not allowed in WHERE clause`

The data definitely has departments with average salary above $50,000. What went wrong?

Why does the database reject Maria's query?

Decision tree: from error symptom to cause and solution.

Common misconception

“I wrote `GROUP BY` before `WHERE` in the query, so `WHERE` runs after `GROUP BY` and can use aggregate functions.”

The clauses are read in the order you type them: `SELECT`, `FROM`, `GROUP BY`, `WHERE`, `HAVING`. It's natural to think execution follows the same order, and that `WHERE` sees the grouped data.

What is actually true

SQL execution order is: FROM → WHERE → GROUP BY → HAVING → SELECT. WHERE always runs before grouping, even if you write it after GROUP BY in the query. Aggregates are calculated during GROUP BY, so they are not available in WHERE. Use HAVING to filter after aggregation.

Wrong vs Right: Filtering by average salary per department

❌ Wrong: aggregate in WHERE

sql4 lines
SELECT department, AVG(salary)
FROM employees
GROUP BY department
WHERE AVG(salary) > 50000;   -- error: aggregate not allowed in WHERE

✅ Right: HAVING after GROUP BY

sql4 lines
SELECT department, AVG(salary)
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000;   -- works, returns departments with avg salary > 50000

✅ Correct use of WHERE (no aggregate)

sql3 lines
SELECT department, salary
FROM employees
WHERE salary > 50000;   -- filters individual rows, no GROUP BY needed

Notice: When you want to filter rows before any grouping, use WHERE. When you want to filter groups after aggregation, use HAVING. They are complementary, not interchangeable.

Check yourself

Test your understanding of the aggregate-in-WHERE error.

1Select all that applyChecked instantly

Which of the following queries will produce an 'aggregate not allowed in WHERE' error? (Select all that apply.)

  • ASELECT department, COUNT(*) FROM employees GROUP BY department HAVING COUNT(*) > 10;
  • BSELECT department FROM employees WHERE MAX(salary) > 100000 GROUP BY department;
  • CSELECT department, AVG(salary) FROM employees GROUP BY department WHERE AVG(salary) > 60000;
  • DSELECT department, SUM(expenses) FROM budget GROUP BY department HAVING SUM(expenses) < 5000;
  • ESELECT product, COUNT(order_id) FROM orders WHERE COUNT(order_id) > 5 GROUP BY product;
2Put in orderChecked instantly

Arrange these SQL clauses in correct execution order (from first to last).

  • AHAVING
  • BWHERE
  • CSELECT
  • DFROM
  • EGROUP BY
3Short answerMarked by AI

Consider this query that returns an error:

sql4 lines
SELECT region, SUM(revenue)
FROM sales
GROUP BY region
WHERE SUM(revenue) > 1000000;

Explain in 2–4 sentences why the error occurs and how to fix it. Be specific about the execution order of WHERE and GROUP BY.

Graded on Identifies that WHERE runs before GROUP BY and aggregates aren't available yet · Proposes the correct fix (HAVING) with explanation · References the specific query context (region, SUM(revenue))

4Select all that applyChecked instantly

Which of the following statements about filtering in SQL are true? (Select all that apply.)

  • AHAVING can filter groups based on aggregate functions.
  • BWHERE can filter rows based on aggregate functions.
  • CWHERE filters rows before grouping; HAVING filters groups after grouping.
  • DYou can use HAVING without GROUP BY, but it treats the whole table as one group.
  • EAn aggregate function in WHERE always causes an immediate syntax error.
5Short answerMarked by AI

A colleague shows you this query:

sql4 lines
SELECT category, AVG(price)
FROM products
WHERE AVG(price) > 20
GROUP BY category;

They ask: 'Why doesn't this work? I want categories where the average price is over $20.' Write a 3-4 sentence explanation including the execution order and the fix.

Graded on Correctly identifies that WHERE runs before GROUP BY and aggregates are unavailable · Explains the fix (move to HAVING) and why it works · Tailors explanation to the specific query (category, AVG(price))

Lesson 28

Joining tables: the real VLOOKUP replacement

You will be able to write an INNER JOIN to combine rows from two tables on a matching key. Covers the ON clause, table aliases, and how a join is just a filtered cross product. Prevents using subqueries where a join belongs.

40 min3 assessments

If you've used VLOOKUP in a spreadsheet, you know the pain of dragging formulas down and hoping the lookup table doesn't shift. SQL joins do the same thing—combine rows from two tables—but they do it with a single command and without fragile cell references.

Here's the core idea: a join takes every row from the first table and pairs it with every row from the second table. That's called a cross product. If you have 5 orders and 10 customers, you get 50 pairs. Most of those pairs are nonsense (order 1 paired with customer 5 who didn't place it). So you add an ON clause that keeps only the pairs where the customer ID matches.

That's it. A join is just a cross product followed by a filter.

Let's see it with real data. Suppose you have an orders table:

order_idcustomer_idamount
110150.00
210275.00

And a customers table:

customer_idname
101Alice
102Bob
103Carol

The cross product of these two tables has 2 × 3 = 6 rows. The ON clause orders.customer_id = customers.customer_id keeps only the two rows where the IDs match. The result:

order_idcustomer_idamountname
110150.00Alice
210275.00Bob

Notice Carol (customer 103) disappears because she has no orders. That's an INNER JOIN: only rows with matches survive. If you wanted Carol in the result even without orders, you'd use a LEFT JOIN—but that's for the next lesson.

Table aliases (orders AS o, customers AS c) shorten your query and avoid ambiguity when both tables have a column with the same name (like customer_id). You'll see them in every real-world query.

In short: a join is a cross product plus a filter. The ON clause is the filter condition. That mental model will help you debug joins when results seem too many or too few.

From two tables to cross product to filtered inner join result.

Analogy

Think of a join like matching puzzle pieces. You have two piles: one with left halves of a picture (table A) and one with right halves (table B). To see the full picture, you try every left half against every right half (cross product) and keep only the pairs whose edges match (ON clause).

In a spreadsheet VLOOKUP, you only get one match per lookup value—the first one found. But in SQL, a join returns all matching pairs. If a left half fits three different right halves, you get three full pictures. That's powerful when you need to combine one-to-many relationships.

Where the analogy breaks down

The puzzle analogy suggests each left half matches at most one right half (one-to-one). In reality, joins often produce many-to-many results, and the ON clause can be any condition, not just an equality check.

Common misconception

“JOIN works just like VLOOKUP: it finds one matching row from the second table and adds it to the first.”

In spreadsheets, VLOOKUP returns the first match for each lookup value. Many people's first exposure to combining tables comes from that tool.

What is actually true

A SQL JOIN returns every row from the first table that satisfies the ON condition, paired with every matching row from the second table. If there are multiple matches, you get multiple result rows. This is why you sometimes get more rows than expected—it's not a bug, it's the correct behavior.

Exercise1 check
sql

Write an INNER JOIN query that returns the name of each employee and their department name. Use table aliases: e for employees and d for departments. The result should have two columns: name and dept_name. Order does not matter.

Tables you can query

  • employees (id, name, department_id)
  • departments (id, dept_name)
starter code4 lines
SELECT e.name, d.dept_name
FROM employees AS e
JOIN departments AS d
  ON e.department_id = d.id;
Check yourself

Check your understanding of joins.

1Multiple choiceChecked instantly

What is the result of an INNER JOIN between a table with 3 rows and another table with 4 rows, if the ON condition matches every row?

  • A3 rows
  • B4 rows
  • C12 rows
  • D7 rows
2Short answerMarked by AI

Explain why a SQL JOIN might return more rows than there are in the first table. Give a concrete example using employees and departments.

Graded on Identifies the cause: multiple matches produce multiple rows · Provides a concrete example

Lesson 29

INNER JOIN syntax

Write an INNER JOIN with the ON clause, selecting columns from both tables. Understand the basic structure: SELECT ... FROM table1 JOIN table2 ON condition.

10 min3 assessments

When you have two tables of related data—like customers and orders—you need a way to combine them. In a spreadsheet, you might use VLOOKUP to pull a customer name into an order list. In SQL, you use JOIN. An INNER JOIN returns only rows that have matching values in both tables. The basic syntax is:

sql4 lines
SELECT columns
FROM table1
JOIN table2
ON table1.key = table2.key;

The ON clause defines how rows are matched. You can also use table aliases to shorten names. For example, FROM orders o JOIN customers c ON o.customer_id = c.id.

An INNER JOIN is the most common join—it's the SQL equivalent of a filtered lookup. Any row from either table that doesn't have a match is dropped.

Flow of an INNER JOIN: customers and orders combine on the matching key.

Joining Customers to Orders

Suppose you have two tables:

customers | id | name | city | |----|-------|------| | 1 | Alice | NY | | 2 | Bob | LA | | 3 | Carol | SF |

orders | id | customer_id | total | |----|-------------|-------| | 101 | 1 | 50.00 | | 102 | 2 | 75.00 | | 103 | 1 | 20.00 | | 104 | 4 | 100.00|

To get each order with the customer name:

sql4 lines
SELECT orders.id, orders.total, customers.name
FROM orders
JOIN customers
ON orders.customer_id = customers.id;

Result: | id | total | name | |-----|-------|-------| | 101 | 50.00 | Alice | | 102 | 75.00 | Bob | | 103 | 20.00 | Alice |

Notice that order 104 (customer_id = 4) is missing from the result because there is no customer with id = 4. That's the INNER JOIN: only matching rows survive.

Steps
0/7

Follow these steps to write an INNER JOIN query. We'll use the customers and orders example.

  1. Identify the two tables

    Decide which table holds the primary key (e.g., customers.id) and which holds the foreign key (e.g., orders.customer_id). The JOIN will match on these.

    If you mix up the tables, the logic still works, but the query will be harder to read.

    You know which column is the matching key in each table.

  2. Choose the columns you want from each table

    List the columns you need, prefixed by the table name or an alias. For example: orders.id, customers.name.

    If a column name exists in both tables (e.g., `id`), you must prefix it; otherwise the database will throw an 'ambiguous column' error.

    Each column reference is either unique or prefixed.

  3. Write the SELECT clause

    Start with SELECT followed by the column list. Optionally use table aliases to shorten the prefix: SELECT o.id, c.name FROM orders o JOIN customers c ON o.customer_id = c.id.

    Forgetting to alias after using an alias in the ON clause will cause an error.

    If you use aliases, they are defined in FROM/JOIN and used consistently.

  4. Write FROM with the first table

    FROM orders (or FROM orders o if using an alias).

    The FROM clause is mandatory; if you omit it, the query fails.

    The table name exists in the database.

  5. Write JOIN with the second table

    JOIN customers (or JOIN customers c).

    Do not use a comma; `FROM orders, customers` is a cross join, not an INNER JOIN.

    The second table is spelled correctly.

  6. Add the ON clause

    ON orders.customer_id = customers.id — this is the matching condition. The left side is the foreign key, the right side is the primary key.

    A common mistake is to reverse the columns or use the wrong column (e.g., `orders.id = customers.id`). This will produce no rows or wrong results.

    The ON condition uses the correct columns from each table.

  7. Run the query and verify the row count

    Execute the query. Compare the number of rows returned to the number of rows in the first table that have matches. You can also SELECT COUNT(*) from the join to check.

    If you get more rows than expected, you may have a duplicate key or a condition that matches multiple rows (one-to-many join).

    The row count matches your expectation (e.g., 3 rows for the example).

Checklist
0/6

INNER JOIN writing checklist

Refer to this checklist each time you write an INNER JOIN until it becomes automatic.

Exercise1 check
sql

Write a query that returns the order ID, total, and customer name for each order. Only include orders that have a matching customer. Use an INNER JOIN.

Tables you can query

  • customers (id, name, city)
  • orders (id, customer_id, total)
starter code1 line
SELECT ...
Check yourself

Check your understanding of INNER JOIN syntax.

1Put in orderChecked instantly

Arrange these steps in the correct order to write an INNER JOIN query.

  • AWrite JOIN with the second table
  • BWrite SELECT with columns
  • CWrite ON with the matching condition
  • DWrite FROM with the first table
2Short answerMarked by AI

You run the query: SELECT * FROM orders JOIN customers ON orders.id = customers.id; but it returns no rows. The orders table has 100 rows, customers has 50. What is the most likely cause? Explain your reasoning.

Graded on Identifies the correct cause (wrong ON condition) · Explains why no rows are returned · Provides the correct fix

Lesson 30

ON clause logic

Understand how the ON clause filters rows by matching values between columns. A join is a filtered cross product: every pair of rows is tested, and only those satisfying the ON condition remain.

10 min4 assessments

You can write an INNER JOIN now. The harder part — and the point of this node — is predicting what comes back: why one row vanishes, why another appears three times, and why a condition that looks right matches nothing.

The answer is in what the ON clause actually is. An INNER JOIN is a two-step machine, and the diagram below is the whole machine: build every row pair, then run the ON condition on each pair. What the diagram cannot show is what the ON clause is made of. It is not a link or a key. It is a boolean condition — the same kind of thing you write in WHERE — evaluated against a row pair instead of a single row.

Because it is a condition, it has a condition's full power. It can compare any columns with any operator: ON o.total > c.min_order, ON o.city = c.city AND o.zip = c.zip. The only thing that matters is whether the condition is true for that specific pair.

Two consequences follow, and both surprise people coming from spreadsheets. A row that matches nothing disappears — no blank cell, no #N/A. A row that matches three partners appears three times. The result is a list of matched pairs, so its row count is the number of pairs, not the number of rows in either table.

The whole join in one picture: every row pair is built first, then the ON condition keeps only the pairs where it is true.

Analogy

The ON clause is a bouncer checking pairs at a door. Two queues arrive — everyone from the left table, everyone from the right. The bouncer pairs them one at a time: left row 1 with right row 1, then left row 1 with right row 2, and so on. Each pair is checked against the same rule — o.customer_id = c.id — and only the pairs that pass walk into the result. The whole join is just 'run every pair past the bouncer'.

Where the analogy breaks down

A bouncer lets each person in once. A join has no such limit: one row can match three partners and appear in three result rows, and the database tests every pair even after a match is found — it never stops at the first match the way VLOOKUP does.

Common misconception

“The ON clause is just naming the columns that link the two tables — like the lookup key in VLOOKUP.”

Most joins you write are equality on a key column (`ON orders.customer_id = customers.id`), so the ON clause looks like a label for 'these two columns go together'.

What is actually true

ON is a boolean condition tested once per row pair. It can compare any columns with any operator, and it decides each pair independently — a row can match zero, one, or several partners. VLOOKUP finds one value and stops; the ON clause tests every pair.

  • A join first builds every row pair from both tables, then keeps only the pairs where the ON condition is true.
  • The ON clause is a boolean expression like a WHERE condition, but evaluated against a row pair instead of a single row.
  • ON can use any operator and any columns: equality, comparison, or several conditions joined with AND.
  • A row with no matching partner produces no result rows; a row with three matching partners produces three.
  • NULL never satisfies an equality in ON: NULL = NULL is not true, so rows with NULL keys never match.
Check yourself

Four checks that the cross-product model is doing the work in your head.

1Multiple choiceChecked instantly

You run SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id. Which rows appear in the result?

  • AEvery row from orders, with empty cells where no customer matches
  • BOnly the row pairs for which the ON condition is true
  • CEvery possible pair of one order and one customer
  • DOnly the rows where both tables have a column with the same name
2Numeric answerChecked instantly

The orders table has four rows with customer_id values 1, 2, 2, 3. The customers table has four rows with id values 1, 2, 3, 4. How many rows does SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id return? Answer with the number only.

3Short answerMarked by AI

Your employees table has 10 rows and your managers table has 3 rows. The query SELECT * FROM employees e JOIN managers m ON e.manager_id = m.id returns 12 rows. A colleague says that is impossible, because 10 employees and 3 managers can produce at most 10 rows. Explain what the 12 rows actually are and where the colleague's reasoning goes wrong.

Graded on Explains the 12 rows as matched employee–manager pairs · Identifies the flaw in the at-most-10 argument

4Short answerMarked by AI

One query filters active customers inside the ON clause: SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id AND c.active = 1. Another puts the same filter in a WHERE clause: SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.active = 1. A teammate says the ON version is better because it discards inactive customers earlier, before the result is built. Using the cross-product model, explain whether the two queries can ever return different rows, and whether the teammate's reason holds up.

Graded on States whether ON versus WHERE changes the result for an INNER JOIN · Judges the filters-earlier claim

Lesson 31

Table aliases

Use short aliases for table names to simplify queries and avoid ambiguity when columns have the same name in different tables.

8 min3 assessments

When you write a JOIN, you type the table name every time you refer to a column. If the table is called customer_order_line_items, your query becomes hard to read. And if two tables have a column with the same name, like id or name, SQL doesn't know which one you mean — it throws an "ambiguous column" error.

Table aliases solve both problems: you give the table a short nickname and use that instead. The syntax is:

sql1 line
FROM table_name AS alias

or just:

sql1 line
FROM table_name alias

The alias is valid only for the duration of that query. Once the query ends, the alias disappears.

For example, instead of:

sql3 lines
SELECT customer_order_line_items.product_id, products.name
FROM customer_order_line_items
JOIN products ON customer_order_line_items.product_id = products.id;

You can write:

sql3 lines
SELECT c.product_id, p.name
FROM customer_order_line_items AS c
JOIN products AS p ON c.product_id = p.id;

Notice how c and p replace the full table names everywhere. The query is shorter, and you can see immediately which column comes from which table.

  • Table aliases reduce typing and improve query readability, especially with long table names.
  • You can define an alias using the AS keyword or by just adding a space after the table name.
  • Aliases are scoped to the current query; they don't persist across queries.
  • Aliases disambiguate columns when two joined tables have columns with the same name.
  • Use meaningful aliases (e.g., e for employees, d for departments) rather than arbitrary letters like t1.
  • Aliases can also be used for subqueries and derived tables (you'll see this later).
Steps
0/5

Let's alias two tables in a join query step by step. You'll start with a query that causes an ambiguity error and fix it using aliases.

  1. Write the join without aliases

    Start with a query that joins employees and departments. Both tables have a column called name.

    sql3 lines
    SELECT name, name
    FROM employees
    JOIN departments ON employees.department_id = departments.id;

    Running this will fail with "Column 'name' in field list is ambiguous" because SQL cannot tell which `name` you mean.

    You see an error message about ambiguity.

  2. Add an alias to the first table

    Add AS e after employees:

    sql3 lines
    SELECT name, name
    FROM employees AS e
    JOIN departments ON e.department_id = departments.id;

    You haven't changed the SELECT yet, so the ambiguity remains.

    The query still fails with the same error.

  3. Add an alias to the second table

    Add AS d after departments:

    sql3 lines
    SELECT name, name
    FROM employees AS e
    JOIN departments AS d ON e.department_id = d.id;

    Still ambiguous in SELECT.

    Error persists.

  4. Use aliases in the SELECT clause

    Replace the bare name columns with e.name and d.name:

    sql3 lines
    SELECT e.name AS employee_name, d.name AS department_name
    FROM employees AS e
    JOIN departments AS d ON e.department_id = d.id;

    If you forget to alias one column, you'll still get an ambiguity error.

    Query runs and returns two columns: employee_name and department_name.

  5. Test the query

    Run the final query in your SQL tool. Verify that each row shows the employee's name and their department's name.

    If you accidentally use the full table name in one place but the alias in another, you'll get an error like 'table name not recognized'.

    The result set has correct pairs of employee and department names.

Checklist
0/5

Writing queries with table aliases

Keep this checklist open while you write your queries. Tick each item after you check your query.

Exercise1 check
sql

Complete the SELECT clause to retrieve:

  • the employee name as employee_name
  • the department name as department_name

Use the aliases e and d that are already defined. Do not change the FROM or JOIN parts.

Tables you can query

  • employees (id, name, department_id)
  • departments (id, name)
starter code4 lines
SELECT 
FROM employees AS e
JOIN departments AS d
  ON e.department_id = d.id;
Go deeper
  • YouTube

    SQL Alias - AS Statement - SQL Tutorial #21

    Data with Baraa·4:07·en

    Open

Watch this 4-minute video for a clear demonstration of aliasing tables and columns in SQL.

Check yourself

Check your understanding of table aliases.

1Put in orderChecked instantly

Arrange the following steps in the correct order to write a join query using table aliases.

  • AUse the alias in the SELECT clause for columns from that table
  • BWrite the table name in FROM or JOIN
  • CAdd an alias after the table name (with or without AS)
  • DRun the query and verify no ambiguity errors
  • EUse the alias in the ON clause for the join condition
2Short answerMarked by AI

Explain why using table aliases is important when joining tables that have columns with the same name. Give a specific example of an error that would occur without aliases.

Graded on Identifies the ambiguity error and provides a concrete example · Explains why aliases resolve the ambiguity

Lesson 32

Join vs subquery

Recognise when to use a join instead of a subquery. Joins are often more efficient and readable for combining rows from related tables on a matching key.

12 min3 assessments

You already know how to write an INNER JOIN. But sometimes you see a subquery instead. Both can answer the same questions, but they differ in performance, readability, and suitability. This comparison helps you choose.

INNER JOIN

Performance (typical)
Faster when combining many rows from related tables
Readability for simple cases
Very readable for multi-table queries
Can return columns from multiple tables in one row
Yes
Best for checking existence
Not ideal; often requires DISTINCT

Best for Retrieving columns from multiple tables where the relationship is direct and you need to filter or aggregate across them.

Subquery (correlated or non-correlated)

Performance (typical)
Slower if not optimized or if subquery is correlated
Readability for simple cases
Readable for simple existence checks (IN, EXISTS)
Can return columns from multiple tables in one row
No; subquery in SELECT can return a single value
Best for checking existence
Yes; EXISTS and IN are natural

Best for Checking existence or membership, or when you need to aggregate in a WHERE clause.

Same result, different approach

Schema: customers(id, name) and orders(id, customer_id, total).

Problem: Find names of customers who have placed at least one order.

Subquery:

sql2 lines
SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders);

Join:

sql3 lines
SELECT DISTINCT customers.name
FROM customers
JOIN orders ON customers.id = orders.customer_id;

Both return the same list. The subquery is often more readable for this simple existence check. The join requires DISTINCT to avoid duplicates if a customer has multiple orders, which may be less efficient. However, for returning multiple columns from the orders table, the join is clearly better.

  • Use JOIN when you need to retrieve columns from multiple tables in the same row.
  • Use a subquery with EXISTS or IN for simple existence checks.
  • Correlated subqueries in SELECT can be rewritten with JOIN and GROUP BY, often with better performance.
  • Database optimizers can sometimes rewrite subqueries as joins, but not always.
  • For readability, start with the approach that matches the mental model: JOIN for combining rows, subquery for checking conditions.
Common misconception

“Subqueries are always slower than joins.”

Many tutorials show that joins are faster, and in many examples a subquery adds extra overhead.

What is actually true

Performance depends on the optimizer, indexes, and the specific query. A subquery with EXISTS can be faster than a join with DISTINCT because it stops after finding the first match. Always test with your data.

Check yourself

Test your understanding of when to use a join vs a subquery.

1Select all that applyChecked instantly

Which of the following scenarios are better suited for a JOIN than a subquery? (Select all that apply)

  • AYou need to retrieve columns from multiple tables in a single row.
  • BYou need to check if a value exists in another table.
  • CYou need to group data from one table and filter based on an aggregate from another table.
  • DYou need to combine two tables on a foreign key and return data from both.
2Short answerMarked by AI

Consider the following query:

sql3 lines
SELECT department_name,
  (SELECT COUNT(*) FROM employees WHERE employees.department_id = departments.id) AS employee_count
FROM departments;

Explain why a JOIN approach might be better here, and write the equivalent query using JOIN and GROUP BY. (2-4 sentences)

Graded on Explains why the subquery is less efficient · Writes correct equivalent JOIN query

3Multiple choiceChecked instantly

Which of the following is true about the performance of JOIN vs subquery?

  • AJoins are always faster than subqueries.
  • BSubqueries are always faster than joins.
  • CPerformance depends on the database optimizer, indexes, and the specific query.
  • DJoins cannot be used for EXISTS conditions.

Lesson 33

LEFT JOIN: keeping unmatched rows

You will be able to write a LEFT JOIN that preserves all rows from the left table, filling unmatched columns with NULL. Covers the difference between INNER and LEFT JOIN, and finding rows that have no match. Prevents losing data unintentionally.

30 min4 assessments

You already know that an INNER JOIN keeps only the rows where both tables have a matching key. That’s great for finding pairs, but it also silently discards rows from the left table that have no match. If you’re reporting all customers — including those who never ordered — an INNER JOIN would simply drop them, and you’d never know they existed.

A LEFT JOIN (or LEFT OUTER JOIN) solves this: it keeps every row from the left table, and for rows without a match in the right table, it fills the right table’s columns with NULL. You get a complete left-side result, with missing data clearly marked as NULL instead of missing rows.

This is the tool you reach for when “show me everything from table A, and attach whatever data exists from table B.”

The choice between INNER JOIN and LEFT JOIN comes down to whether you need to preserve all rows from one side. Here’s how they compare on the axes that actually matter.

INNER JOIN

Rows returned from left table
Only rows with a match
Unmatched right-table columns
Dropped entirely
NULLs appear in result
Never
Performance on large tables
Faster (fewer rows to process)
Typical use case
Finding pairs that exist in both tables

Best for When you only care about records that have a counterpart in the other table — e.g., orders that have a customer, or employees with a department.

LEFT JOIN

Rows returned from left table
All rows, matched or not
Unmatched right-table columns
Filled with NULL
NULLs appear in result
Yes, for unmatched rows
Performance on large tables
Slightly slower (must keep all left rows)
Typical use case
Reporting all records from one table with optional details from another — e.g., all customers with their order totals, including those who never ordered.

Customers and orders: INNER vs LEFT

Two tables:

customers | customer_id | name | |-------------|------------| | 1 | Ada | | 2 | Grace | | 3 | Alan |

orders | order_id | customer_id | amount | |----------|-------------|--------| | 101 | 1 | 150.00 | | 102 | 1 | 75.00 | | 103 | 2 | 200.00 |

INNER JOIN

sql3 lines
SELECT c.name, o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;

Result: | name | amount | |-------|--------| | Ada | 150.00 | | Ada | 75.00 | | Grace | 200.00 |

Alan is missing — he has no orders, so INNER JOIN drops him.

LEFT JOIN

sql3 lines
SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

Result: | name | amount | |-------|--------| | Ada | 150.00 | | Ada | 75.00 | | Grace | 200.00 | | Alan | NULL |

Alan appears with NULL in the amount column. The left table (customers) is preserved completely.

Exercise1 check
sql

The starter query uses a LEFT JOIN to list all employees with their department names. Run it and observe that Carol appears with NULL in dept_name because she has no department. Then modify the query to show only employees who have no department (i.e., department_id is NULL). Hint: add a WHERE clause that checks for NULL in the right table's key.

Tables you can query

  • employees (id, name, department_id)
  • departments (id, dept_name)
starter code3 lines
SELECT e.name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id;
Check yourself

Check your understanding of when and how to use LEFT JOIN.

1Select all that applyChecked instantly

Which of the following statements about LEFT JOIN are true? (Select all that apply.)

  • AA LEFT JOIN always returns at least as many rows as an INNER JOIN on the same tables and keys.
  • BA LEFT JOIN can be used to find rows in the left table that have no match in the right table.
  • CA LEFT JOIN is always faster than an INNER JOIN because it doesn't filter.
  • DIn a LEFT JOIN, NULLs appear only in columns from the right table for rows that had no match.
2Multiple choiceChecked instantly

You need a report showing every product in your catalog, along with the total quantity sold for each product. Products that have never been sold should still appear with a quantity of NULL. Which join should you use?

  • AINNER JOIN products with sales
  • BLEFT JOIN products with sales
  • CRIGHT JOIN products with sales
  • DCROSS JOIN products with sales
3Short answerMarked by AI

Examine this query:

sql4 lines
SELECT c.name, o.order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_date IS NULL;

What does this query return? Explain why the WHERE clause is placed after the JOIN and what would change if the condition were moved into the ON clause.

Graded on Identifies that the query returns customers with no orders · Explains the effect of moving condition to ON clause · Clarity and precision of SQL reasoning

Lesson 34

Other join types at a glance

You will be able to identify when RIGHT JOIN, FULL OUTER JOIN, and CROSS JOIN are used, even if you rarely write them. Covers the syntax and the Venn diagram that misleads everyone. Prevents confusion when reading someone else's query.

15 min3 assessments

You can already write a LEFT JOIN that keeps every row from the left table. But queries you inherit will also use RIGHT JOIN, FULL OUTER JOIN, and CROSS JOIN. Each one answers a different question about which rows to keep and how to pair them. The Venn diagrams you see online are misleading—they show set overlap, but joins are about row preservation, not set membership. This lesson gives you a cheat sheet and a worked example so you can read any join at a glance.

The three joins differ in which rows survive and what happens when there is no match.

RIGHT JOIN

Rows retained
All rows from the right table
Matching condition
ON clause
NULLs from missing side
Left side columns filled with NULL
When to use
When you need every row from the lookup table, even if the main table has no matching row

Best for Reports where the right table is the master list (e.g., all departments, even those with no employees)

FULL OUTER JOIN

Rows retained
All rows from both tables
Matching condition
ON clause
NULLs from missing side
Both sides may have NULLs
When to use
When you need a complete picture of both tables, including orphans on either side

Best for Reconciliation queries where you want to see records that exist in one table but not the other

CROSS JOIN

Rows retained
All rows from both tables (cartesian product)
Matching condition
No ON clause (every row pairs with every row)
NULLs from missing side
No NULLs from join (all combinations are produced)
When to use
When you need every combination of two sets (e.g., all product × all store for inventory)

Best for Generating a grid of all possible pairs, such as a date table crossed with a location table

Employees and Departments

Tables:

employees

emp_idnamedept_id
1Alice10
2Bob20
3CarolNULL

departments

dept_iddept_name
10Engineering
20Sales
30HR

RIGHT JOIN – keep all departments, even if no employee:

sql3 lines
SELECT e.name, d.dept_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;

Result: | name | dept_name | |-------|-------------| | Alice | Engineering | | Bob | Sales | | NULL | HR |

FULL OUTER JOIN – keep all rows from both sides:

sql3 lines
SELECT e.name, d.dept_name
FROM employees e
FULL OUTER JOIN departments d ON e.dept_id = d.dept_id;

Result: | name | dept_name | |-------|-------------| | Alice | Engineering | | Bob | Sales | | Carol | NULL | | NULL | HR |

CROSS JOIN – every combination, no condition:

sql3 lines
SELECT e.name, d.dept_name
FROM employees e
CROSS JOIN departments d;

Result (3 × 3 = 9 rows): | name | dept_name | |-------|-------------| | Alice | Engineering | | Alice | Sales | | Alice | HR | | Bob | Engineering | | Bob | Sales | | Bob | HR | | Carol | Engineering | | Carol | Sales | | Carol | HR |

Check yourself

Test your ability to choose the right join.

1Select all that applyChecked instantly

Which of the following join types will include rows that have no matching counterpart in the other table? (Select all that apply.)

  • AINNER JOIN
  • BLEFT JOIN
  • CRIGHT JOIN
  • DFULL OUTER JOIN
  • ECROSS JOIN
2Short answerMarked by AI

You have two tables: orders and customers. You need a list of every order, including orders placed by customers who are no longer in the customers table, AND you also want to see any customer who has never placed an order. Which join type should you use? Explain why a FULL OUTER JOIN is appropriate here.

Graded on Join type identification · Explanation of appropriateness

3Multiple choiceChecked instantly

What is the result of a CROSS JOIN between a table of 5 rows and a table of 3 rows?

  • A15 rows
  • B8 rows
  • C5 rows
  • D3 rows

Lesson 35

LEFT JOIN syntax

Write a LEFT JOIN to keep all rows from the left table. Use LEFT JOIN ... ON condition, and see how unmatched rows get NULLs for right-table columns.

10 min3 assessments

You already know that LEFT JOIN keeps every row from the left table, even when the right table has no match. Now you need to write that syntax correctly. The command is:

sql3 lines
SELECT columns
FROM left_table
LEFT JOIN right_table ON condition;

The ON clause tells SQL which columns to compare between the two tables. If no row in right_table satisfies the ON condition, the result still includes the left_table row, but all right_table columns become NULL.

A common slip is writing WHERE instead of ON for the join condition, or forgetting the ON clause entirely. ON is not optional in a LEFT JOIN – without it you get a cross join (every row paired with every row), which is almost never what you want.

Authors and their books

Two tables:

authors | id | name | |----|------------| | 1 | Ada Lovelace | | 2 | Grace Hopper | | 3 | Alan Turing |

books | id | title | author_id | |----|---------------------|-----------| | 1 | Notes on the Analytic Engine | 1 | | 2 | Understanding Programming | 2 |

Query:

sql3 lines
SELECT a.name, b.title
FROM authors a
LEFT JOIN books b ON a.id = b.author_id;

Result: | name | title | |---------------|--------------------------------| | Ada Lovelace | Notes on the Analytic Engine | | Grace Hopper | Understanding Programming | | Alan Turing | NULL |

Alan Turing has no books, so his row still appears with NULL in the title column.

Steps
0/5

Write a LEFT JOIN that keeps all rows from the left table. Follow these steps in order.

  1. Choose the left table

    Decide which table must keep all its rows. That table goes in the FROM clause. Example: FROM customers.

    If you put the wrong table first, you'll lose rows from the table you wanted to keep.

    You have one table name after `FROM`.

  2. Write the LEFT JOIN clause

    Type LEFT JOIN followed by the second table. Example: LEFT JOIN orders.

    Omitting `LEFT` gives an `INNER JOIN`, which drops unmatched rows. Double-check the keyword.

    The line reads `LEFT JOIN` (two words), not `JOIN` alone.

  3. Add the ON condition

    Write ON then the equality that links the tables. Usually left_table.foreign_key = right_table.primary_key. Example: ON customers.id = orders.customer_id. Use table aliases if you have long names.

    A missing `ON` clause causes a cross join (every row from left paired with every row from right). Always include `ON`.

    The `ON` condition references columns from both tables and uses `=`.

  4. Select columns from both tables

    In the SELECT list, include the columns you need. Prefix columns with table aliases when the same column name exists in both tables. Example: SELECT c.name, o.order_date.

    If you select a right-table column for a row that had no match, that column is `NULL`. Your application must handle nulls.

    You have at least one column from the left table and one from the right table (if you want to see the join effect).

  5. Run and verify

    Execute the query. Count the rows: it should equal the number of rows in the left table. Check that unmatched rows have NULL in right-table columns.

    If you get the same number of rows as the left table but no `NULL`s, you may have accidentally written an `INNER JOIN` or the `ON` condition is too permissive (matches every row anyway).

    Row count equals `SELECT COUNT(*) FROM left_table`.

Checklist
0/5

LEFT JOIN syntax checklist

Keep this open while you write your query. Tick each item after you check it.

  • YouTube

    SQL LEFT JOIN - SQL Tutorial #23

    Watch the first 2 minutes to see the LEFT JOIN syntax written step by step with a live example. Pay attention to how the ON condition is placed and what happens to unmatched rows.

    Data with Baraa·3:31·en

    Open
Exercise2 checks
sql

Complete the ON condition to join the authors and books tables on the correct foreign key column. The query should return all authors, including Jane Austen who has no books.

Tables you can query

  • authors (id, name)
  • books (id, title, author_id)
starter code3 lines
SELECT a.name, b.title
FROM authors a
LEFT JOIN books b ON __;
Check yourself

Check your understanding of LEFT JOIN syntax.

1Put in orderChecked instantly

Arrange these steps in the correct order to write a LEFT JOIN that keeps all rows from the left table.

  • AWrite `LEFT JOIN` followed by the right table name
  • BWrite `ON` and the join condition
  • CSelect columns from both tables
  • DWrite `FROM` followed by the left table name
  • ERun the query and verify row count equals left table count
2Short answerMarked by AI

You run this query: SELECT * FROM employees LEFT JOIN departments ON employees.dept_id = departments.id; and you get 10 rows. The employees table has 10 rows. The departments table has 4 rows. What does this tell you about the relationship between employees and departments? Explain why there are no NULLs in the result.

Graded on Identifies that all employees have matching departments · Explains the absence of NULLs · Logical inference about the data

Lesson 36

NULLs in LEFT JOIN

Understand that unmatched rows produce NULL values in columns from the right table. Learn to handle or filter these NULLs in your results.

8 min4 assessments

When you write a LEFT JOIN, every row from the left table survives in the result. If a row has no match in the right table, SQL fills the right table's columns with NULL — a marker that means "no value", not "zero" and not an empty string.

You already know that LEFT JOIN customer ON orders.customer_id = customer.id keeps orders even when customer_id is missing. What matters now is what happens next: those NULLs appear in your result set, and they change how WHERE, GROUP BY and COALESCE behave.

A NULL is not equal to anything — not even another NULL. WHERE total IS NULL works; WHERE total = NULL does not. This is the single most common mistake people make after their first LEFT JOIN.

Reference

NULL behaviour after LEFT JOIN

Filter rows with no match

What happens with NULLs
WHERE col = NULL is always false
Correct way to write it
WHERE col IS NULL

Filter rows that do have a match

What happens with NULLs
WHERE col <> NULL is always false
Correct way to write it
WHERE col IS NOT NULL

Substitute a default value

What happens with NULLs
NULL stays NULL
Correct way to write it
COALESCE(col, 'Unknown')

Group by a column that has NULLs

What happens with NULLs
NULLs form their own group
Correct way to write it
Use COALESCE if you want a label

Join on a column that might be NULL

What happens with NULLs
NULL never matches anything
Correct way to write it
Use IS NULL in the ON clause or avoid it

Count a column that has NULLs

What happens with NULLs
COUNT(col) skips NULLs; COUNT(*) counts the row
Correct way to write it
Choose COUNT(*) or COUNT(1) for row count

Compare two nullable columns

What happens with NULLs
a = b is false if either is NULL
Correct way to write it
a IS NOT DISTINCT FROM b (PostgreSQL) or explicit checks

NULL is not a value — it is the absence of one. Every operator and aggregate treats it differently from a real value.

Orders with missing payments

Tables:

orders | id | customer | amount | |----|----------|--------| | 1 | Ada | 120 | | 2 | Grace | 40 | | 3 | Alan | 200 |

payments | order_id | paid | |----------|------| | 1 | 120 | | 3 | 200 |

Query:

sql3 lines
SELECT o.id, o.customer, p.paid
FROM orders o
LEFT JOIN payments p ON o.id = p.order_id;

Result: | id | customer | paid | |----|----------|-------| | 1 | Ada | 120 | | 2 | Grace | NULL | | 3 | Alan | 200 |

Order 2 (Grace) has no matching payment row, so p.paid is NULL. If you wrote WHERE paid = NULL, you would lose every row — including Grace's. Use WHERE paid IS NULL to find unpaid orders.

Practice
0/10 known
1 / 10
Exercise1 check
sql

Write a query that lists employees who have a manager_id set, but that manager does not exist in the employees table. Show the employee's name and the manager's name (which will be NULL). The employees table has columns: id, name, manager_id.

Tables you can query

  • employees (id, name, manager_id)
starter code6 lines
-- Find employees whose manager has been deleted (manager_id points to a non-existent employee)
SELECT e.name, m.name AS manager_name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
WHERE m.id IS NULL
  AND e.manager_id IS NOT NULL;
Check yourself

Check your understanding of NULLs in LEFT JOIN.

1Multiple choiceChecked instantly

After a LEFT JOIN, you want only rows where the right table had a match. Which WHERE clause works?

  • A`WHERE right_table.key = NULL`
  • B`WHERE right_table.key IS NOT NULL`
  • C`WHERE right_table.key <> NULL`
  • D`WHERE right_table.key = 0`
2Select all that applyChecked instantly

Which of the following statements about NULLs in SQL are true? (Select all that apply.)

  • A`NULL = NULL` evaluates to true
  • B`COUNT(col)` excludes rows where col is NULL
  • C`WHERE col IS NULL` correctly finds NULL values
  • D`COALESCE(col, 0)` returns 0 when col is NULL
  • EA NULL in a join key can match another NULL in an INNER JOIN
3Fill the blanksChecked instantly

Lesson 37

Finding unmatched rows

Use a WHERE clause with IS NULL on a right-table column to find rows from the left table that have no match in the right table.

10 min3 assessments

You already know that a LEFT JOIN keeps every row from the left table, filling the right-table columns with NULL when there is no match. Now you need the rows that have no match — the customers with zero orders, the students who never enrolled, the products never sold.

The pattern is always the same: LEFT JOIN + WHERE right_table.key IS NULL. The IS NULL filters out the matched rows, leaving only the unmatched ones. This is the SQL equivalent of a spreadsheet "find unmatched" query or an anti-join.

In the next node you will combine this pattern with aggregation. For now, master the two-line recipe.

Steps
0/4

You have two tables: customers (id, name) and orders (id, customer_id, amount). You need a list of customers who have never placed an order.

  1. Start the SELECT and FROM

    SELECT customers.name FROM customers

    Be sure to qualify the column with the table name — `customers.name` — to avoid ambiguity once you join.

    The query runs and returns all customer names before the join.

  2. Add the LEFT JOIN

    LEFT JOIN orders ON customers.id = orders.customer_id

    If you use an INNER JOIN, unmatched rows disappear immediately — you must use LEFT JOIN. Also double-check the ON condition matches the correct foreign key.

    The result now shows every customer row, but order columns (like `orders.id`) are NULL for customers without an order.

  3. Filter with WHERE … IS NULL

    WHERE orders.id IS NULL

    You must write `IS NULL`, not `= NULL`. In SQL `= NULL` always evaluates to unknown (neither true nor false) and no rows are returned. Also pick a column from the right table that is guaranteed to be NULL when there is no match — typically the primary key or a NOT NULL column like `orders.id`.

    Run it. Only customers with no orders appear.

  4. Select the columns you need

    Adjust the SELECT list: SELECT customers.id, customers.name or any other columns from the left table. The right-table columns will all be NULL so you rarely need them.

    Do not include right-table non-nullable columns in the SELECT — they are always NULL and clutter the output.

    The result set contains only the columns you chose, for unmatched rows only.

Checklist
0/5

When writing a find-unmatched query

Keep this open in a tab while you write your query. Tick each item after you verify it.

Exercise1 check
sql

The database has two tables: students (id, name) and enrollments (id, student_id, course). Write a query that returns the names of students who have no enrollments.

  • Complete the WHERE clause.
  • The result should be a single column name containing only "Bob".

Tables you can query

  • students (id, name)
  • enrollments (id, student_id, course)
starter code4 lines
SELECT s.name
FROM students s
LEFT JOIN enrollments e ON s.id = e.student_id
WHERE
Check yourself

Check your understanding of the find-unmatched pattern.

1Short answerMarked by AI

A beginner writes: SELECT * FROM customers LEFT JOIN orders ON customers.id = orders.customer_id WHERE orders.id = NULL; The query returns zero rows, even though there are customers without orders. Explain what is wrong and how to fix it. (2-4 sentences)

Graded on Identifies the NULL comparison error · Provides the correct fix

2Put in orderChecked instantly

Arrange the following steps in the correct order to build a find-unmatched query. The steps are already in SQL-like terms; put them in the sequence you would write them.

  • ASELECT the columns you need from the left table
  • BFROM the left table
  • CLEFT JOIN the right table ON the matching condition
  • DWHERE a right-table column IS NULL

Lesson 38

Subqueries and CTEs: queries inside queries

You will be able to write a subquery in a FROM or WHERE clause and refactor it into a Common Table Expression for readability. Covers correlated and uncorrelated subqueries, WITH clauses, and when each is the right tool. Prevents writing a ten-deep nested subquery that nobody can debug.

35 min3 assessments

You already know how to write a query that returns a single answer. But what if you need to filter by the result of another query, or create a temporary table that you can reuse in the same query? That's where subqueries and CTEs come in. A subquery is a query inside another query, often used in WHERE or FROM clauses. A Common Table Expression (CTE) lets you name that query and refer to it multiple times, improving readability. Both solve the same problem, but they differ in how you write them, how they execute, and when you should reach for each. This lesson will help you decide which fits your situation.

Subqueries and CTEs both let you use one query inside another, but they shine in different situations. Here’s how they stack up on the axes that matter.

Subquery in WHERE clause

Readability
Lower – can be hard to read when nested deeply
Reusability within query
None – the subquery result is used only once
Recursive capability
No
Execution behavior (materialization)
Usually flattened into the outer query by the optimizer
Best used for
Simple filters where you need a single value or list (e.g., IN, EXISTS)

Best for Quick filters that don't need to be reused, like 'find customers with orders over $100'

Subquery in FROM clause (derived table)

Readability
Medium – requires an alias and can be nested
Reusability within query
None – cannot be referenced by name outside the query
Recursive capability
No
Execution behavior (materialization)
May be materialized or flattened; depends on database
Best used for
Intermediate calculations that are only needed once, like 'average per department'

Best for One-off aggregations where you need to query the result of a GROUP BY

CTE (WITH clause)

Readability
High – clearly named, reads top-down
Reusability within query
Yes – can be referenced multiple times in the same query
Recursive capability
Yes (with RECURSIVE)
Execution behavior (materialization)
Often materialized (optimization fence) in PostgreSQL; inlined in others
Best used for
Complex queries with multiple steps, recursive queries, or when the same sub-result is needed more than once

Best for Multi-step queries that benefit from clarity, like 'find top 10% earners per department'

Find customers who placed more than 5 orders

Schema:

code2 lines
orders (customer_id, order_date, amount)
customers (customer_id, name)

Goal: Get customer names and IDs for those with more than 5 orders.

Subquery (WHERE clause):

sql8 lines
SELECT *
FROM customers
WHERE customer_id IN (
  SELECT customer_id
  FROM orders
  GROUP BY customer_id
  HAVING COUNT(*) > 5
);

The subquery runs once, returns a list of customer IDs, and the outer query filters by that list.

CTE (WITH clause):

sql9 lines
WITH frequent_customers AS (
  SELECT customer_id, COUNT(*) AS order_count
  FROM orders
  GROUP BY customer_id
  HAVING COUNT(*) > 5
)
SELECT c.*
FROM customers c
JOIN frequent_customers fc ON c.customer_id = fc.customer_id;

The CTE gives the aggregated result a name. If you later need to join the same result to another table, you can reference frequent_customers again without rewriting the subquery.

Why it matters: Both queries produce the same result. The CTE version is easier to read and modify, especially when the logic grows.

Common misconception

“CTEs are always faster than subqueries because they are evaluated only once.”

A CTE is defined once and then referenced by name, so it's natural to think the database runs it once and reuses the result, avoiding repeated work.

What is actually true

Execution depends on the database. In PostgreSQL, CTEs are optimization fences – they are materialized (run once and stored) even when the optimizer could flatten a subquery into a more efficient plan. This can make CTEs slower for simple cases. In other databases (SQL Server, MySQL), CTEs are often inlined like subqueries. Performance is not guaranteed; always test both if speed matters.

Go deeper

Watch these for a deeper dive into execution differences and advanced use cases like recursive CTEs.

Check yourself

Check your understanding of when to use subqueries and CTEs.

1Select all that applyChecked instantly

Which of the following statements about subqueries and CTEs are correct? Select all that apply.

  • AA subquery can be used in the WHERE clause with IN.
  • BA CTE can be referenced multiple times in a single query.
  • CA CTE always improves query performance.
  • DA subquery in the FROM clause must have an alias.
2Short answerMarked by AI

You have a table employees(id, name, department_id, salary). Write a query using a CTE to find the average salary per department, and then list all employees who earn more than the average salary in their own department. Provide the SQL query.

Graded on Correct CTE structure · Correct join and condition · Syntax

3Multiple choiceChecked instantly

Which of the following is a valid reason to use a CTE instead of a subquery?

  • AThe CTE is always faster.
  • BThe CTE can be referenced multiple times in the same query.
  • CThe CTE cannot be used in the FROM clause.
  • DThe CTE is only available in MySQL.

Lesson 39

Subquery in WHERE clause

Use a subquery inside a WHERE clause with IN, EXISTS, or comparison operators to filter rows based on results from another query.

12 min3 assessments

You can already write a subquery in a FROM clause to create a temporary table. Now you'll use a subquery inside WHERE to filter rows based on a condition that comes from another query. In spreadsheets, you might use VLOOKUP to pull a value and then filter manually; here, the database does it in one step. For example, find all customers who have placed an order over $500. Instead of joining and grouping, you write a subquery that returns the customer IDs with high orders, then use WHERE customer_id IN (that subquery).

A subquery in WHERE must return a single column (or a single value for comparison operators like =). The outer query then uses that result set to decide which rows to keep. This is the most common way to filter based on aggregated or conditional data from another table.

Steps
0/4

We'll write a query that finds all customers who have placed an order over $500. Use the tables customers (id, name) and orders (id, customer_id, total).

  1. Write the inner query

    Start by writing the query that returns the customer IDs you want: SELECT customer_id FROM orders WHERE total > 500;

    The inner query must select exactly one column – the one you'll compare in the outer WHERE clause.

    Run the inner query alone to see it returns a list of customer IDs (e.g., 1, 3).

  2. Write the outer query

    Write the outer query to select customer names: SELECT name FROM customers WHERE id IN (...);

    The column in the outer WHERE clause (`id`) must match the data type and meaning of the column returned by the inner query (`customer_id`).

  3. Combine with IN

    Place the inner query inside parentheses after IN: SELECT name FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE total > 500);

    Forget the parentheses and you'll get a syntax error. Always wrap the subquery in parentheses.

    Run the combined query. You should see names of customers with orders over $500.

  4. Test with edge cases

    Add a customer who has no orders and see if they are excluded. Also test with a subquery that returns NULLs (e.g., SELECT customer_id FROM orders WHERE total > 99999 returns no rows).

    When the subquery returns no rows, `IN ()` evaluates to false, so the outer query returns nothing – that's correct. But if the subquery contains NULLs, `IN` behaves differently: `x IN (NULL, 1)` is unknown if x is not 1, so the row may be excluded unexpectedly. Use `EXISTS` instead if NULLs are possible.

    Verify that customers with no orders are not in the result.

Checklist
0/6

Subquery in WHERE – Field Checklist

Keep this checklist open while writing subqueries in WHERE. Tick each item before moving to the next query.

Exercise1 check
sql

Write a query that returns the names of customers who have placed at least one order with total greater than 400. Use a subquery in the WHERE clause with IN. The tables are already created and populated.

Tables you can query

  • customers (id, name)
  • orders (id, customer_id, total)
starter code3 lines
SELECT name FROM customers WHERE id IN (
  SELECT customer_id FROM orders WHERE total > 400
);

Using EXISTS instead of IN

Sometimes you need to check existence rather than match a list. EXISTS is often faster for correlated subqueries.

Problem: Find customers who have placed any order (regardless of total).

Solution with EXISTS:

sql5 lines
SELECT name FROM customers c
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.customer_id = c.id
);
  • EXISTS stops scanning as soon as it finds one matching row.
  • The subquery is correlated: it references c.id from the outer query.
  • SELECT 1 is a convention; the actual columns don't matter because EXISTS only checks for rows.

When to use which:

  • Use IN when the subquery returns a small, static list of values.
  • Use EXISTS when the subquery is correlated or when NULLs might appear (EXISTS handles NULLs correctly).
  • A subquery in WHERE must return exactly one column.
  • Use IN when the inner query returns multiple values; use =, >, etc. when it returns a single value.
  • EXISTS is preferred for correlated subqueries and handles NULLs correctly.
  • Always wrap the subquery in parentheses.
  • Test the inner query separately before combining.
  • Be aware of performance: an uncorrelated subquery runs once, a correlated subquery runs per outer row.
Check yourself

Check your understanding of subqueries in WHERE.

1Put in orderChecked instantly

Arrange the following steps in the correct order for writing a subquery in WHERE with IN.

  • AWrite the inner SELECT statement
  • BRun the inner query to verify results
  • CWrite the outer SELECT with WHERE ... IN
  • DRun the combined query and check output
2Short answerMarked by AI

You write the following query and get an error: SELECT name FROM customers WHERE id = (SELECT customer_id FROM orders WHERE total > 500); The error says the subquery returns more than one row. Explain why this happens and how to fix it.

Graded on Identifies the cause: subquery returns multiple rows · Proposes the correct fix: use IN · Explains why the fix works

  • YouTube

    Using Subquery in WHERE Clause | Oracle SQL fundamentals

    Watch a live demonstration of writing a subquery in WHERE with IN and EXISTS. Pay attention to the parentheses and the single-column rule.

    DBA Genesis·6:05·en

    Open
Go deeper

For a deeper dive into correlated subqueries and performance considerations, read the Wikipedia article on correlated subqueries.

Lesson 40

Subquery in FROM clause

Use a subquery as a derived table in the FROM clause. Wrap the subquery in parentheses, give it an alias, and select from it as if it were a table.

12 min3 assessments

You already know how to use a subquery to filter rows in a WHERE clause. But what if you need to aggregate data first and then query the results? For example, you want to find customers whose total purchases exceed $100. You can't write WHERE SUM(amount) > 100 directly—aggregates aren't allowed in WHERE. That's where a subquery in the FROM clause comes in.

Think of it like this: in a spreadsheet, you might create a pivot table on a new sheet summarizing totals, then write a formula that references that summary. A subquery in FROM does exactly that—it creates a temporary derived table (the pivot sheet) that you can then query like any other table.

Syntax

sql3 lines
SELECT outer_columns
FROM (subquery) AS alias
WHERE conditions;

The subquery inside parentheses runs first, producing a result set. That result set is given an alias (mandatory!) and treated as a table in the outer query.

Example Table orders:

order_idcustomer_idamount
1150.00
2175.00
32200.00
4230.00
53120.00

Goal: Find customers whose total amount > 100.

  1. 1.Inner query: SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id
  2. 2.Wrap it in parentheses and give it an alias: (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) AS customer_totals
  3. 3.Outer query: SELECT customer_id, total FROM customer_totals WHERE total > 100;

Result:

customer_idtotal
1125.00
2230.00
3120.00

The subquery runs first, creating a derived table with two columns (customer_id and total). The outer query then reads from that derived table and applies the filter.

Steps
0/5

Follow these steps to write a subquery in the FROM clause. Each step includes what to do, what can go wrong, and how to verify it worked.

  1. Write the inner query

    Decide what data you need to pre‑compute. Write a complete SELECT statement that returns the columns you'll need in the outer query. For example: SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id

    Make sure the inner query is valid on its own. If it has an error, the entire query will fail.

    Run the inner query alone to verify it returns the expected rows and columns.

  2. Wrap the inner query in parentheses

    Put the entire inner query inside a pair of parentheses ( ). This is required to tell SQL that it's a subquery.

    Don't forget the closing parenthesis. Also, do not put a semicolon inside the parentheses—the subquery is not a complete statement.

    Count the parentheses: there should be one opening and one closing.

  3. Give the subquery an alias

    Right after the closing parenthesis, write AS followed by a name for the derived table. For example: AS customer_totals. The alias is mandatory—every derived table must have a name.

    Choose a short, descriptive alias that doesn't conflict with any real table name in the database. Avoid reserved words like `order` or `group`.

    The alias appears after the closing parenthesis. In your outer query, you will reference this alias in the FROM clause or in column references.

  4. Write the outer query

    Now write the SELECT statement that queries the alias. For example: SELECT customer_id, total FROM customer_totals WHERE total > 100;. You can use any columns produced by the inner query.

    If you reference a column that doesn't exist in the inner query's output, you'll get an error. Also, be careful about ambiguous column names—if the inner query renames a column (like `SUM(amount) AS total`), use that new name.

    The outer query should reference the alias in the FROM clause. Verify that column names match exactly what the inner query outputs.

  5. Run the complete query

    Execute the entire statement. The database will first run the inner query, then run the outer query against the result.

    If you get an error like 'every derived table must have its own alias', you forgot the alias. If you get 'unknown column', check your column names.

    The result set should contain only the data you expect from the outer query's filters and projections.

Checklist
0/5

Subquery-in-FROM Quick Checklist

Keep this checklist open while writing your query. Tick each item off before running.

Exercise1 check
sql

Write a query that uses a subquery in the FROM clause to find customers whose total order amount exceeds $100. The subquery should calculate the total per customer, and the outer query should return the customer_id and total_amount columns. The table orders is already created.

Tables you can query

  • orders (order_id, customer_id, amount, order_date)
starter code5 lines
SELECT customer_id, total_amount
FROM (SELECT customer_id, SUM(amount) AS total_amount
      FROM orders
      GROUP BY customer_id) AS customer_totals
WHERE total_amount > 100;
  • YouTube

    We Learn SQL #12 | SQL Subqueries in FROM

    Watch this video for a live, step‑by‑step demonstration of writing a subquery in the FROM clause, complementing the written walkthrough with a real database interaction.

    We Learn SQL·4:45·en

    Open
  • A subquery in FROM produces a temporary derived table that you can query like a regular table.
  • Always wrap the subquery in parentheses and give it a mandatory alias.
  • The outer query can use any column produced by the inner query, including aliased expressions.
  • Derived tables are useful for pre‑aggregation, filtering after GROUP BY, or simplifying complex joins.
  • You can use multiple derived tables in a single FROM clause by joining them.
  • The inner query runs first; the outer query sees only the result of the inner query.
Check yourself

Check your understanding of subqueries in the FROM clause.

1Short answerMarked by AI

Examine the following query:

SELECT * FROM (SELECT name, AVG(score) AS avg_score FROM students GROUP BY name) WHERE avg_score > 80;

What is wrong with this query? Explain the issue and how to fix it.

Graded on Identifies the missing alias · Explains why the alias is mandatory · Suggests the correct fix

2Put in orderChecked instantly

Put the steps to write a subquery in the FROM clause in the correct order.

  • AWrite the outer query that selects from the alias
  • BWrite the inner query that produces the intermediate data
  • CWrap the inner query in parentheses
  • DAssign an alias to the subquery
  • ERun the complete query

Lesson 41

Correlated subqueries

Understand how a correlated subquery references columns from the outer query and is re-evaluated for each row. Contrast with uncorrelated subqueries.

15 min3 assessments

In a spreadsheet, if you want to find employees who earn more than the average salary in their department, you might calculate the average per department in a separate sheet, then use VLOOKUP to bring that average into each row, and finally filter. In SQL, you can achieve the same result with a correlated subquery—a subquery that depends on a value from the outer query and runs once for each row of the outer query.

Consider the employees table: | id | name | salary | department_id | |----|------|--------|---------------| | 1 | Alice| 5000 | 1 | | 2 | Bob | 6000 | 1 | | 3 | Carol| 4000 | 2 | | 4 | Dave | 4500 | 2 |

To get employees who earn more than their department average, you write:

sql7 lines
SELECT name, salary
FROM employees e
WHERE salary > (
  SELECT AVG(salary)
  FROM employees
  WHERE department_id = e.department_id
);

The subquery references e.department_id from the outer query—this is what makes it correlated. For each row in the outer result, the database engine runs the inner subquery with that row's department ID, computes the average, and then checks the condition.

This contrasts with an uncorrelated subquery, which runs once and returns a static set. For example, getting all employees whose salary is above the company-wide average would use an uncorrelated subquery: WHERE salary > (SELECT AVG(salary) FROM employees).

Correlated subqueries are powerful for per-row comparisons, but they can be slower on large datasets because of the repeated execution. Many databases optimize them automatically, but you should be aware of the mechanism.

  • A correlated subquery references columns from the outer query and is re-evaluated for each row of the outer query.
  • It is placed inside a WHERE clause, often with operators like =, >, IN, or EXISTS.
  • The subquery can use the outer row's values to filter or aggregate data from the same or another table.
  • Correlated subqueries are conceptually similar to a loop in procedural code, but the database optimizer may transform them into joins for performance.
  • They are most useful when you need to compare each row against a value that depends on that row itself, such as a group average.

How a correlated subquery executes row by row

Analogy

Think of a correlated subquery like a helper column in a spreadsheet that you create by dragging a formula down. The formula references the current row's value (e.g., =AVERAGEIF(DepartmentRange, A2, SalaryRange)). As you drag it, each row gets its own calculation based on its own department. The correlated subquery does the same: for each row in the outer query, it runs a query that uses that row's value.

But the analogy breaks down in two ways:

  1. 1.Dynamic vs. static: A dragged formula is computed once when you enter it, but the correlated subquery is re-evaluated every time the outer query runs (e.g., if you run the query again, it recalculates from scratch).
  2. 2.Return type: A helper column returns a single value per row, but a correlated subquery can return multiple values (used with IN or EXISTS) or a scalar (used with =).

Where the analogy breaks down

The analogy breaks when the subquery returns multiple rows (e.g., with EXISTS) because a spreadsheet helper column returns a single value per row; also, the spreadsheet formula is static once written, while the SQL subquery is re-run each time the query executes.

Employees earning above their department average

Data: employees table | id | name | salary | department_id | |----|--------|--------|---------------| | 1 | Alice | 5500 | 1 | | 2 | Bob | 4500 | 1 | | 3 | Carol | 6000 | 2 | | 4 | Dave | 4000 | 2 | | 5 | Eve | 7000 | 3 | | 6 | Frank | 3000 | 3 |

Query:

sql7 lines
SELECT name, salary
FROM employees e
WHERE salary > (
  SELECT AVG(salary)
  FROM employees
  WHERE department_id = e.department_id
);

Execution for one row: For Alice (department 1, salary 5500), the subquery computes AVG(salary) WHERE department_id = 1 which is (5500+4500)/2 = 5000. Since 5500 > 5000, Alice is included. For Bob (salary 4500, same department), 4500 < 5000, so he is excluded. The result: | name | salary | |-------|--------| | Alice | 5500 | | Carol | 6000 | | Eve | 7000 |

Common misconception

“Correlated subqueries are always inefficient and should be avoided.”

They are executed once per row, so for a table with 1000 rows, the subquery runs 1000 times. That seems like a loop, and loops are slow in databases.

What is actually true

Modern database optimizers are smart. They often rewrite correlated subqueries into joins or semi-joins that execute more efficiently. For small tables or when the outer query has a selective filter, the performance difference is negligible. The readability of a correlated subquery can be worth it, especially when the logic is naturally expressed as a per-row check. However, for very large datasets, you should profile and consider rewriting as a join or using window functions.

  • YouTube

    We Learn SQL #13 | Intro to correlated subqueries in SQL

    This 5-minute video walks through building a correlated subquery step by step, showing the row-by-row execution visually. It reinforces the difference between correlated and uncorrelated subqueries.

    We Learn SQL·4:56·en

    Open
Check yourself

Check your understanding of correlated subqueries.

1Multiple choiceChecked instantly

Which of the following is true about a correlated subquery?

  • AIt runs once and returns a single value that is used by the outer query.
  • BIt references columns from the outer query and is re-evaluated for each row of the outer query.
  • CIt can only be used in the SELECT clause of the outer query.
  • DIt is always faster than a JOIN because it avoids combining tables.
2Short answerMarked by AI

Explain why you would use a correlated subquery instead of a regular subquery or JOIN to find products that have never been ordered. (Assume tables: products(id, name) and orders(id, product_id)).

Graded on Identifies the need for correlation · Explains why a correlated subquery is appropriate here

3Short answerMarked by AI

Analyze the following query: SELECT name FROM employees e WHERE EXISTS (SELECT 1 FROM orders o WHERE o.employee_id = e.id AND o.total > 1000). Is this a correlated subquery? Why?

Graded on Correctly identifies correlation · Explains why it is correlated

Lesson 42

Common Table Expressions

Write a CTE using the WITH clause to define a temporary named result set. Improve readability and enable recursive queries.

12 min3 assessments

You already know how to write a subquery — a query nested inside another query. But when a subquery appears in the middle of a long statement, it's hard to read, impossible to reuse, and you can't refer to it more than once. A Common Table Expression (CTE) fixes all three: you give the subquery a name, define it at the top with WITH, and then use that name like a table anywhere in the main query. CTEs also unlock recursive queries, which subqueries cannot do. This lesson shows you the exact steps to write a CTE, the pitfalls to watch for, and a checklist you can keep open while you work.

Steps
0/4

You'll write a CTE that finds the top 3 products by total revenue. Start with the subquery that calculates revenue per product, then wrap it in a CTE, then query the CTE.

  1. Write the inner subquery

    sql3 lines
    SELECT product_id, SUM(quantity * price) AS total_revenue
    FROM order_details
    GROUP BY product_id;

    Run this alone to confirm it returns one row per product with the correct total.

    If you forget the GROUP BY, you'll get one row per order detail, not per product.

    The result has as many rows as there are distinct product_ids.

  2. Wrap it in a CTE with WITH

    sql6 lines
    WITH product_revenue AS (
      SELECT product_id, SUM(quantity * price) AS total_revenue
      FROM order_details
      GROUP BY product_id
    )
    SELECT * FROM product_revenue;

    Place the CTE definition before the main SELECT. The CTE name (product_revenue) is now usable as a table.

    A semicolon after the CTE's closing parenthesis breaks the query. The only semicolon is at the very end.

    The query returns the same rows as the subquery in step 1.

  3. Query the CTE with ordering and limit

    sql9 lines
    WITH product_revenue AS (
      SELECT product_id, SUM(quantity * price) AS total_revenue
      FROM order_details
      GROUP BY product_id
    )
    SELECT product_id, total_revenue
    FROM product_revenue
    ORDER BY total_revenue DESC
    LIMIT 3;

    The CTE behaves like a temporary table — you can filter, sort, join, or aggregate it.

    CTEs are not persisted. They vanish after the main query finishes. If you need the same data in multiple places, define it once and reference it by name.

    Only three rows, sorted from highest revenue to lowest.

  4. Use multiple CTEs (optional advanced step)

    sql13 lines
    WITH
      revenue AS (
        SELECT product_id, SUM(quantity * price) AS total
        FROM order_details GROUP BY product_id
      ),
      top_products AS (
        SELECT product_id FROM revenue
        ORDER BY total DESC LIMIT 3
      )
    SELECT p.product_name, r.total
    FROM products p
    JOIN revenue r ON p.product_id = r.product_id
    JOIN top_products t ON p.product_id = t.product_id;

    Separate CTEs with a comma. Each CTE can reference earlier CTEs in the same WITH clause.

    CTEs are evaluated in order. You cannot reference a CTE before it's defined.

    The final result shows product names and revenue for only the top three products.

Checklist
0/6

Writing a CTE — Field Checklist

Keep this list open in a separate tab while you write your first CTEs. Tick each item after you verify it.

Exercise2 checks
sql

Complete the query to find the name of the customer who spent the most. Use the CTE customer_totals and join with the customers table. Return only one row: the customer's name and their total spent.

Tables you can query

  • customers (id, name)
  • orders (id, customer_id, amount)
starter code6 lines
WITH customer_totals AS (
  SELECT customer_id, SUM(amount) AS total_spent
  FROM orders
  GROUP BY customer_id
)
-- Write your main query below:
  • YouTube

    Advanced SQL Tutorial | CTE (Common Table Expression)

    Watch a live demonstration of writing a CTE from scratch, including common syntax mistakes and how to debug them.

    Alex The Analyst·3:44·en

    Open
  • A CTE is a named subquery defined with WITH name AS (subquery) before the main SELECT.
  • CTEs improve readability by moving complex subqueries to the top of the statement.
  • Multiple CTEs are separated by commas; each can reference earlier CTEs in the same WITH.
  • CTEs are temporary — they exist only for the duration of the query and cannot be reused outside it.
  • Recursive CTEs (using WITH RECURSIVE) allow queries that reference themselves, enabling tree traversal and running totals.
  • CTEs can be referenced multiple times in the same query, unlike a subquery which is duplicated.
Check yourself

Check your understanding of CTEs.

1Put in orderChecked instantly

Arrange the steps to write a correct CTE that finds the average order value per customer, then lists customers above that average.

  • AWrite the main SELECT that joins the CTE and filters.
  • BDefine a CTE that calculates total spent per customer.
  • CDefine a second CTE that computes the overall average from the first CTE.
  • DWrite the WITH keyword and separate the two CTEs with a comma.
2Short answerMarked by AI

You have a query that uses the same subquery three times. Why would you refactor it into a CTE? Name at least two benefits.

Graded on Identifies readability/maintainability benefit · Identifies performance benefit

Lesson 43

CTE vs subquery

Decide when to use a CTE versus a subquery. CTEs improve readability for complex queries and can be referenced multiple times.

10 min3 assessments

You already know how to write a subquery and refactor it into a CTE. Now the real skill is deciding which one to reach for. The wrong choice doesn't break your query, but it makes the next developer (or you, next month) work harder to understand what it does — and, in some databases, it can hurt performance. This node gives you the decision axes so you stop guessing.

The table below uses the axes that actually decide the choice — not "ease of use" but whether the structure lets you reuse results, improve readability, or enable features that a subquery cannot.

Subquery

Reusability (reference the result multiple times)
No — each occurrence must be rewritten
Readability for deeply nested queries (>2 levels)
Poor — nesting >2 levels quickly becomes unreadable
Performance hint (planner materialization)
No — the planner typically inlines it
Supports recursion
No
Can reference itself in the same statement
No

Best for Simple, one-off filtering or aggregation that needs no reuse and stays at one level of nesting.

CTE (WITH clause)

Reusability (reference the result multiple times)
Yes — define once, reference multiple times
Readability for deeply nested queries (>2 levels)
Excellent — flattens nesting into named steps
Performance hint (planner materialization)
Yes — materialized by default in PostgreSQL (can be overridden)
Supports recursion
Yes
Can reference itself in the same statement
Yes — enables recursive queries

Best for Complex queries that reference the same derived table more than once, recursive tasks (e.g., tree traversals), or any query where readability is the top priority.

Employee salary vs department average — same result, different intentions

Schema: employees(id, name, salary, department_id)

Goal: For each employee, show their salary, the average salary in their department, and the difference.

sql6 lines
SELECT
  e.name,
  e.salary,
  (SELECT AVG(salary) FROM employees WHERE department_id = e.department_id) AS dept_avg,
  e.salary - (SELECT AVG(salary) FROM employees WHERE department_id = e.department_id) AS diff
FROM employees e;

Problem: The same aggregation appears twice. The database may compute it twice (unless the optimizer is clever). The query is harder to read and any change must be made in two places.

sql14 lines
WITH dept_avg AS (
  SELECT
    department_id,
    AVG(salary) AS avg_salary
  FROM employees
  GROUP BY department_id
)
SELECT
  e.name,
  e.salary,
  da.avg_salary AS dept_avg,
  e.salary - da.avg_salary AS diff
FROM employees e
JOIN dept_avg da ON e.department_id = da.department_id;

Advantage: The average is computed once, the CTE is materialized (in many databases), and the final query reads like a sequence of steps: first compute department averages, then join them in.

Go deeper
  • YouTube

    CTE vs Subquery vs View: Which One Should You Use?

    Database Star·4:39·en

    Open

Watch this 4-minute video for a practical walkthrough that reinforces the comparison — especially when a CTE might accidentally become a performance trap.

  • CTEs let you reference the same derived result multiple times without rewriting the query.
  • CTEs improve readability by flattening nested subqueries into named, sequential steps.
  • In many databases (e.g., PostgreSQL), CTEs are materialized as temporary tables, which can be a performance hint — but also a trap if you forget it acts as a fence.
  • Recursive queries are only possible with CTEs (WITH RECURSIVE).
  • A single-use, non-recursive subquery that is easy to read is fine as a subquery — no need to refactor every inline query into a CTE.
Common misconception

“CTEs are always faster than subqueries because they avoid repeating work.”

A CTE does compute a result once, so you avoid redundant computation when you reference it multiple times. That seems like a clear performance win.

What is actually true

CTEs can be slower than subqueries in single-use scenarios because the CTE is materialized (written to a temporary table) and then scanned, whereas a subquery may be inlined and optimised together with the outer query. The database planner has to honour the CTE as an optimisation fence, so a CTE can prevent index usage or join reordering that would have been applied to an inline subquery. Always measure with your data and dialect.

Check yourself

Check your understanding with these questions.

1Multiple choiceChecked instantly

When would you choose a CTE over a subquery?

  • AWhen the derived table is used only once and the query is short
  • BWhen you need to reference the same derived result in several places
  • CWhen you need the fastest possible execution for a simple filter
  • DWhen you want to force the database to inline the query
2Select all that applyChecked instantly

Which of the following are advantages of CTEs compared to subqueries? (Select all that apply.)

  • ACTEs support recursion
  • BCTEs are always executed only once, even if referenced multiple times in some databases
  • CCTE names are automatically indexed
  • DCTEs can be reused in the same query without rewriting
  • ECTEs make complex queries easier to read by separating steps
3Short answerMarked by AI

You are writing a query that needs to calculate each product's total sales, then find products whose total sales are greater than the average total sales across all products. The result of the average is used only once. Would you use a CTE or a subquery? Explain your reasoning briefly.

Graded on Identifies the correct choice (subquery or CTE) and justifies it based on reuse and overhead · Acknowledges when the decision would change (reuse scenario) · Writing clarity and use of SQL concepts in explanation

Lesson 44

Window functions: calculations across rows

You will be able to write ROW_NUMBER, RANK, and a running total with SUM OVER, without collapsing rows. Covers PARTITION BY, ORDER BY in a window, and the frame clause. Prevents using a self-join for a running total.

45 min3 assessments

Window functions let you perform calculations across rows that are related to the current row—without collapsing those rows into a single result. Think of them as a way to attach an aggregate or ranking value to every row, based on a group of rows you define.

Suppose you have an employees table with columns name, department, and salary. You want to show each employee’s salary alongside the average salary for their department. A GROUP BY would give you one row per department, losing the individual employees. A window function keeps every row:

sql3 lines
SELECT name, department, salary,
       AVG(salary) OVER (PARTITION BY department) AS dept_avg
FROM employees;

Here, PARTITION BY department divides the rows into groups (partitions) by department. For each row, AVG(salary) is computed over all rows in that partition. The result repeats the same average for every employee in the same department, but each employee row remains intact.

Window functions are evaluated after WHERE, GROUP BY, and HAVING, but before ORDER BY. They can use PARTITION BY to split rows into groups, ORDER BY to order rows within each partition, and a frame clause to further restrict which rows are included in the calculation.

How a window function processes rows without collapsing them.

Analogy

Imagine you have a spreadsheet with sales data sorted by date. You want a running total of revenue. You type =SUM($A$2:A2) in cell B2 and drag it down. For each row, the formula sums from the first row to the current row. That’s exactly what SUM(revenue) OVER (ORDER BY date) does—it computes a cumulative sum across rows in the order you specify.

Now suppose you have multiple salespeople and you want a running total per salesperson. In a spreadsheet, you’d have to manually reset the range for each person or use a more complex formula. With window functions, you simply add PARTITION BY salesperson to reset the running total for each group.

Where the analogy breaks down

A spreadsheet running total requires the data to be sorted exactly as you need it, and you must manually drag the formula to each new row. A window function works on any ordering defined in the query, handles partitions automatically, and never needs manual extension—it’s evaluated dynamically for every row in the result set.

Ranking and running totals per salesperson

Consider a sales table:

salespersonyearamount
Alice2020100
Alice2021150
Bob2020200
Bob2021180

Query:

sql6 lines
SELECT salesperson, year, amount,
       ROW_NUMBER() OVER (PARTITION BY salesperson ORDER BY year) AS rn,
       RANK() OVER (PARTITION BY salesperson ORDER BY amount DESC) AS rank,
       SUM(amount) OVER (PARTITION BY salesperson ORDER BY year) AS running_total
FROM sales
ORDER BY salesperson, year;

Result:

salespersonyearamountrnrankrunning_total
Alice202010012100
Alice202115021250
Bob202020011200
Bob202118022380
  • ROW_NUMBER() assigns a unique sequential number within each partition (by salesperson), ordered by year.
  • RANK() gives the rank based on amount DESC; ties would get the same rank, and the next rank would skip a number.
  • SUM(amount) OVER (PARTITION BY salesperson ORDER BY year) computes a running total per salesperson, year by year.
  • YouTube

    SQL Window Functions in 10 Minutes

    Watch the first 5 minutes to see a clear visual explanation of how window functions keep rows separate from aggregates, with live examples.

    Colt Steele·10:13·en

    Open
Common misconception

“Window functions are just GROUP BY with extra columns.”

Both can compute aggregates (SUM, AVG, COUNT) over groups defined by a column. In both cases you write something like `PARTITION BY department` or `GROUP BY department`, and the aggregate result appears.

What is actually true

GROUP BY collapses all rows in each group into a single summary row. Window functions keep every original row and attach the aggregate value to each row individually. The number of output rows is the same as the number of input rows. This difference is critical when you need to combine detail and summary in one result set.

Check yourself

Check your understanding of window functions.

1Multiple choiceChecked instantly

Which of the following is true about window functions?

  • AThey collapse rows like GROUP BY.
  • BThey compute values over a set of rows but keep all original rows.
  • CThey can only be used with the SUM function.
  • DThey require a HAVING clause to filter results.
2Short answerMarked by AI

Explain the difference between ROW_NUMBER() and RANK() when there are ties. Provide a small example with data and the output of each function.

Graded on Correctly states the behavior of ROW_NUMBER and RANK regarding ties. · Provides a clear, accurate example with data and output.

3EssayMarked by AI

Given a table sales with columns region, month, revenue, you need to compute a running total of revenue per region ordered by month. Write the SQL query and explain how the window frame accumulates the sum. Additionally, argue why using a self-join to compute the running total would be a worse approach, focusing on readability, performance, and correctness.

Graded on Correctness of the SQL query to compute running total per region. · Explanation of how the window frame accumulates the sum. · Comparison with self-join approach, covering readability, performance, and correctness.

Lesson 45

ROW_NUMBER and RANK

Use ROW_NUMBER() and RANK() to assign sequential numbers or ranks to rows within a partition. Understand the difference when ties exist.

12 min3 assessments

You already know that SUM() OVER can calculate a running total across rows without collapsing them. ROW_NUMBER() and RANK() are two more window functions that assign numbers to rows — but they behave differently when there are ties.

ROW_NUMBER() gives each row a unique sequential number within its partition, starting at 1. If two rows have the same value in the ORDER BY column, the database still assigns them different numbers — the order among ties is arbitrary but deterministic (usually based on physical storage).

RANK() gives the same number to tied rows, then skips the next number. If two rows tie for 1st, both get rank 1, and the next row gets rank 3.

Both require an ORDER BY clause inside the OVER() to define the sequence. You can also use PARTITION BY to restart the numbering within each group.

ROW_NUMBER vs RANK with ties

Consider a table employees:

idnamedepartmentsalary
1AliceSales50000
2BobSales60000
3CarolSales60000
4DaveEngineering70000
5EveEngineering70000
6FrankEngineering80000

Query:

sql4 lines
SELECT name, department, salary,
       ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
       RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rk
FROM employees;

Result:

namedepartmentsalaryrnrk
BobSales6000011
CarolSales6000021
AliceSales5000033
FrankEngineering8000011
DaveEngineering7000022
EveEngineering7000032

Notice: In Sales, Bob and Carol tie at 60000. ROW_NUMBER gives Bob 1, Carol 2 (order arbitrary). RANK gives both 1, then Alice gets 3. In Engineering, Dave and Eve tie at 70000: ROW_NUMBER gives 2 and 3, RANK gives both 2, then Frank gets 1.

Steps
0/5

Write a query that assigns row numbers to employees within each department, ordered by hire date (oldest first).

  1. Start with SELECT and the columns you need

    Write SELECT employee_id, name, department, hire_date.

    Don't forget to include the columns you want to see in the result.

    You see the column names in your draft.

  2. Add ROW_NUMBER() with OVER()

    Add , ROW_NUMBER() OVER ( after the last selected column. The parentheses after OVER are where you define the window.

    Missing the parentheses after OVER will cause a syntax error.

    The query still parses (you can run it, even if incomplete).

  3. Define the PARTITION BY clause

    Inside the OVER parentheses, write PARTITION BY department. This resets the row number for each department.

    If you omit PARTITION BY, the row number runs across all rows, not per group.

    The query now groups by department in the window.

  4. Add ORDER BY to define the numbering order

    After PARTITION BY, add ORDER BY hire_date ASC. Use ASC for oldest first, DESC for newest first. Close the OVER parentheses.

    ORDER BY is required for ROW_NUMBER. Without it, the database will order arbitrarily and you may get inconsistent results.

    The window function is now complete: `ROW_NUMBER() OVER (PARTITION BY department ORDER BY hire_date ASC)`.

  5. Add FROM clause and run the query

    Write FROM employees and execute. Inspect the row numbers: they restart at 1 for each department and increase by hire date.

    If you see row numbers not restarting, check that PARTITION BY is spelled correctly and that the table has the column.

    The result shows row numbers starting at 1 for each department, ordered by hire date.

Checklist
0/6

Checklist for using ROW_NUMBER and RANK

Keep this checklist open while writing your queries.

Exercise2 checks
sql

Complete the query to add two window functions:

  • ROW_NUMBER() assigned within each region, ordered by amount descending.
  • RANK() assigned within each region, ordered by amount descending.

Use the aliases rn and rk.

Tables you can query

  • sales (id, salesperson, region, amount)
starter code6 lines
SELECT salesperson, region, amount,
       -- TODO: add ROW_NUMBER() per region ordered by amount descending
       AS rn,
       -- TODO: add RANK() per region ordered by amount descending
       AS rk
FROM sales;
  • YouTube

    SQL Window Functions | Clearly Explained | PARTITION BY, ORDER BY, ROW_NUMBER, RANK, DENSE_RANK

    Watch the section on ROW_NUMBER and RANK (around 3:00–6:00) to see the functions in action with a live demo, including how ties are handled.

    Maven Analytics·7:52·from 3:00·en

    Open
  • ROW_NUMBER() assigns a unique sequential integer to each row within a partition, starting at 1.
  • When ORDER BY values are tied, ROW_NUMBER breaks the tie arbitrarily but deterministically.
  • RANK() assigns the same rank to tied rows, then skips the next integer(s).
  • Both require an ORDER BY clause inside the OVER() window definition.
  • PARTITION BY is optional; without it, the numbering applies to the entire result set.
  • Use ROW_NUMBER when you need a unique identifier per group; use RANK when you need to show ties with gaps.
Check yourself
1Short answerMarked by AI

The table scores has rows: (1, 'Alice', 85), (2, 'Bob', 90), (3, 'Carol', 90), (4, 'Dave', 80). A query uses SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS rn, RANK() OVER (ORDER BY score DESC) AS rk FROM scores. The result shows Bob and Carol both with rn=2 and rk=1? Actually rn=2 and rn=3? Explain what the actual output will be and why ROW_NUMBER and RANK differ for ties.

Graded on Identifies the correct ROW_NUMBER output (unique numbers, tie order arbitrary) · Identifies the correct RANK output with tie handling and skip · Explains the fundamental difference in behavior

2Put in orderChecked instantly

Arrange these steps in the correct order to write a query using ROW_NUMBER with PARTITION BY and ORDER BY.

  • AAdd the FROM clause and table name
  • BWrite SELECT and list the columns you want
  • CAdd ROW_NUMBER() OVER ( with PARTITION BY and ORDER BY inside
  • DClose the OVER parentheses and add any other columns or functions
  • ERun the query and verify the row numbers

Lesson 46

SUM OVER for running totals

Write a running total using SUM(column) OVER (ORDER BY ...). Understand how the window frame accumulates values row by row.

12 min4 assessments

Your pivot table already showed you monthly sales totals. But now you need to see the running total—how much has accumulated by each day, not just the totals at month end. A self-join could do it, but that query balloons as your table grows. SUM(amount) OVER (ORDER BY date) gives you the running total in one pass, row by row, without collapsing the detail rows you still need to inspect.

You already know ROW_NUMBER and RANK from the previous node. SUM OVER works the same way: OVER defines a window of rows, and SUM computes the aggregate within that window. The trick is that the default window frame for SUM when ORDER BY is present begins at the first row of the partition and ends at the current row. That's what creates the running total.

Steps
0/3

You have a table orders with columns order_date (date) and amount (numeric). You want a running total of amount ordered by order_date. Follow these steps.

  1. Start with a SELECT that returns the detail rows

    SELECT order_date, amount FROM orders ORDER BY order_date;

    If you forget `ORDER BY`, the result order is undefined and the running total will be meaningless.

    The rows appear in date order. Your query returns exactly what you expect.

  2. Add the window function column

    Change the query to: SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date) as running_total FROM orders ORDER BY order_date;

    Don't forget the alias `running_total` — without it the column name in the result set defaults to the whole expression (ugly and hard to reference in client code).

    The result set now has three columns. Look at the first row: `running_total` equals `amount`. For the second row, `running_total` equals `amount` for row1 + `amount` for row2.

  3. Verify the frame is what you expect

    Add a fourth column to double-check: COUNT(*) OVER (ORDER BY order_date) as rows_in_frame. This shows how many rows the SUM includes for each row.

    If you see `rows_in_frame` always equal to the total row count, you accidentally omitted `ORDER BY` inside `OVER` — with no `ORDER BY` the frame is the whole partition, and SUM becomes a global total.

    For the Nth row, `rows_in_frame` equals N. The frame is growing properly.

Checklist
0/5

Before you run a query with a running total

Run a quick sanity check on a few rows by hand before trusting the whole result.

Monthly orders running total

Table orders:

order_dateamount
2024-01-05100
2024-01-08150
2024-02-01200
2024-02-1550

Query:

sql4 lines
SELECT order_date, amount,
       SUM(amount) OVER (ORDER BY order_date) as running_total
FROM orders
ORDER BY order_date;

Result:

order_dateamountrunning_total
2024-01-05100100
2024-01-08150250
2024-02-01200450
2024-02-1550500

The first row: frame = rows 1 to 1 → SUM = 100. Second row: frame = rows 1 to 2 → SUM = 100+150 = 250. And so on.

Exercise1 check
sql

Write a query that returns sale_date, amount, and a running total of amount ordered by sale_date. Name the running total column running_total.

Tables you can query

  • sales (sale_date, amount)
starter code5 lines
SELECT sale_date, amount,
       -- TODO: write the running total here
       AS running_total
FROM sales
ORDER BY sale_date;
Check yourself

Test your understanding of running totals.

1Put in orderChecked instantly

Arrange these steps in the correct order to add a running total of score ordered by exam_date to a table exams.

  • AWrite SELECT exam_date, score
  • BAdd , SUM(score) OVER (ORDER BY exam_date) AS running_total
  • CAdd ORDER BY exam_date at the end of the query
  • DRun the query and verify the first few rows.
2Short answerMarked by AI

Explain what the default window frame is for SUM(column) OVER (ORDER BY date) and why that causes a running total rather than a total for the whole table. (2-4 sentences)

Graded on Identifies the frame boundaries correctly (UNBOUNDED PRECEDING to CURRENT ROW) · Connects the frame to the accumulating behavior

Project

Customer running total

You have a table transactions with columns customer_id, trans_date, amount. Write a query that returns, for each transaction, the customer's running total of amounts ordered by transaction date. The result should show customer_id, trans_date, amount, and customer_running_total. Add a comment above the query explaining what the window frame does.

Constraints: Use PARTITION BY customer_id so each customer's total resets.

Deliverables

  • 01The complete SQL query (including the comment).
  • 02A sample output for a small set of example data you include in a comment.
ProjectMarked by AI

Produce the query and example output as described in the project brief.

Graded on Query uses SUM OVER with PARTITION BY and ORDER BY correctly · Comment explains the window frame correctly · Example output matches the query logic

Lesson 47

PARTITION BY

Use PARTITION BY to divide rows into groups and apply the window function separately within each group. Similar to GROUP BY but without collapsing rows.

10 min3 assessments

You already know that SUM(amount) OVER () gives a running total across all rows. But what if you need a running total per region — one sequence for East, another for West, resetting at each boundary?

That is what PARTITION BY does. It splits the rows into groups — partitions — and the window function restarts its calculation inside each group. The rows are not collapsed into one row per group the way GROUP BY does. Every original row stays, and beside it you see the window result for its partition.

Look at this query:

sql6 lines
SELECT 
  region,
  sale_date,
  amount,
  SUM(amount) OVER (PARTITION BY region ORDER BY sale_date) AS running_total_per_region
FROM sales;

For every row, running_total_per_region is the sum of amount for that region up to that date. When the region changes, the sum resets to zero and starts again. The ORDER BY inside the window controls the order within each partition — without it, the sum would be the total for the whole partition on every row.

PARTITION BY is similar to GROUP BY in that it defines groups, but the similarity ends there. GROUP BY reduces each group to a single row; PARTITION BY keeps all rows and adds a column computed within each group. You can use any window function — ROW_NUMBER, RANK, SUM, AVG, MAX, MIN, COUNT — and each one will operate independently inside each partition.

How PARTITION BY splits rows into groups and applies the window function within each group.

Analogy

Think of a deck of cards. You want the average value of cards within each suit. You first partition the deck by suit — hearts go in one pile, spades in another, etc. Then you compute the average inside each pile. The result is a single average per suit, but you still have all 52 cards in front of you, each tagged with its suit's average.

PARTITION BY is the same: it splits the rows into piles (partitions) and the window function runs separately on each pile. Every original row remains, and beside it you see the result for its pile.

Where the analogy breaks down

In the card analogy, the piles are physically separate and you can shuffle them independently. In SQL, all partitions are still part of the same query result set, and the order of rows across partitions depends on the overall `ORDER BY` clause (if any). Also, the card analogy suggests that the partition result is computed once per pile and then attached to each card, but in SQL the window function can depend on the order within the partition (e.g., running total), which the analogy doesn't capture.

  • PARTITION BY divides rows into groups without collapsing them — every original row stays.
  • A window function with PARTITION BY restarts its calculation at the beginning of each partition.
  • PARTITION BY is used inside the OVER clause of a window function, after the function name.
  • You can combine PARTITION BY with ORDER BY inside the window to control the order within each partition.
  • GROUP BY collapses rows; PARTITION BY does not — they serve different purposes.
  • Any window function (SUM, AVG, ROW_NUMBER, RANK, etc.) can be used with PARTITION BY.

Running total per department

Given a table employees:

idnamedepartmentsalary
1AliceEngineering70000
2BobEngineering80000
3CarolMarketing60000
4DaveMarketing65000
5EveEngineering90000

Query:

sql10 lines
SELECT 
  name,
  department,
  salary,
  SUM(salary) OVER (
    PARTITION BY department 
    ORDER BY salary
  ) AS running_dept_total
FROM employees
ORDER BY department, salary;

Result:

namedepartmentsalaryrunning_dept_total
AliceEngineering7000070000
BobEngineering80000150000
EveEngineering90000240000
CarolMarketing6000060000
DaveMarketing65000125000

Notice: The running total resets to 60000 when the department changes to Marketing. The overall ORDER BY sorts the final result, but the window order is separate.

Go deeper
  • YouTube

    Intermediate SQL Tutorial | Partition By

    Alex The Analyst·4:14·en

    Open

Watch this 4-minute video for a live walkthrough of PARTITION BY with examples.

Check yourself

Check your understanding of PARTITION BY.

1Multiple choiceChecked instantly

What is the main difference between GROUP BY and PARTITION BY in SQL?

  • AGROUP BY collapses rows into one per group; PARTITION BY keeps all rows and adds a window result.
  • BGROUP BY can only be used with aggregate functions; PARTITION BY can only be used with window functions.
  • CPARTITION BY requires an ORDER BY clause; GROUP BY does not.
  • DThere is no difference; they are synonyms.
2Short answerMarked by AI

Given a table orders(order_id, customer_id, amount, order_date), write a query that shows each order along with the running total of amount for that customer, ordered by order_date. Use a window function with PARTITION BY.

Graded on Correct use of PARTITION BY and window function · Correct ordering and completeness · Syntax and correctness

3Short answerMarked by AI

Examine this query: SELECT department, employee, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rank FROM employees; It returns the following rows: Engineering, Eve, 90000, 1; Engineering, Bob, 80000, 2; Marketing, Dave, 65000, 3; Marketing, Carol, 60000, 4; Engineering, Alice, 70000, 5. Explain why the row numbers are not resetting per department and how you would modify the query to get row numbers that restart for each department.

Graded on Identifies missing PARTITION BY · Provides correct modification · Explains the behavior

Lesson 48

Window frame clause

Control the set of rows used by a window function with ROWS or RANGE between. Specify a frame start and end relative to the current row.

15 min4 assessments

When you write SUM(salary) OVER (ORDER BY hire_date), the database doesn't just add up all rows from the start to the current row — it uses a frame that defines which rows are included. By default, the frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That works fine until you have ties in the ORDER BY column. Then RANGE includes all rows with the same value, even if they come after the current row in physical order. That's often not what you want for a running total.

The frame clause lets you take control. You write:

sql1 line
ROWS | RANGE BETWEEN frame_start AND frame_end
  • ROWS counts rows by their physical position in the partition (1st, 2nd, 3rd…).
  • RANGE counts rows by their value relative to the current row's ORDER BY value (e.g., all rows with a date within 30 days).

Common frame boundaries:

  • UNBOUNDED PRECEDING — all rows before the current row.
  • N PRECEDING — the N rows before (or values within N units).
  • CURRENT ROW — the current row itself.
  • N FOLLOWING — the N rows after.
  • UNBOUNDED FOLLOWING — all rows after.

For example, a 7-day moving average of sales:

sql4 lines
AVG(amount) OVER (
  ORDER BY sale_date
  RANGE BETWEEN INTERVAL '3' DAY PRECEDING AND INTERVAL '3' DAY FOLLOWING
)

This includes every row whose sale_date is within 3 days of the current row's date — even if there are 100 rows on that single day. With ROWS, you'd get exactly 7 rows (if available), regardless of dates.

  • YouTube

    Window FRAMES in SQL | Difference in ROWS and RANGE | SQL Intermediate Level Interview Question

    Watch the first 3 minutes to see a live demo of how ROWS and RANGE behave differently when there are duplicate ORDER BY values. The visual comparison makes the abstract frame concept concrete.

    Select Star·5:11·en

    Open

Frame inclusion for ROWS vs RANGE when the current row has salary 4000. ROWS takes exactly one row before and one after; RANGE takes all rows whose salary is within 1000 of 4000.

Analogy

Think of the window frame as a camera viewfinder. The current row is the focus point. ROWS is like a fixed zoom that shows exactly N rows above and N rows below — no matter what those rows contain. RANGE is like a zoom based on distance: you set a range of values (e.g., salary within 1000), and the viewfinder shows every row whose value falls inside that range, even if they are far apart in the ordered list.

Where the analogy breaks down

A camera zoom always shows contiguous space, but `RANGE` can skip over rows that don't meet the value condition, creating a non‑contiguous set of rows. Also, a camera zoom is symmetric (same number of rows above and below), while a frame can be asymmetric (e.g., `5 PRECEDING AND CURRENT ROW`).

Common misconception

“ROWS and RANGE are interchangeable — they give the same result, so I can just use ROWS all the time.”

When the ORDER BY column has no duplicate values, both produce identical frames. Many examples online use unique values, so learners never see a difference.

What is actually true

RANGE includes all rows whose ORDER BY value is within the specified range relative to the current row's value, which can include rows that are not physically adjacent. ROWS includes only the exact number of rows before or after, regardless of their values. The two give different results when there are ties or gaps in the ORDER BY column.

Running total with duplicate dates

Consider a table of daily sales:

sale_dateamount
2024-01-01100
2024-01-01150
2024-01-02200
2024-01-03250

Query using default frame (RANGE):

sql3 lines
SELECT sale_date, amount,
       SUM(amount) OVER (ORDER BY sale_date) AS running_total
FROM sales;

Result:

sale_dateamountrunning_total
2024-01-01100250← includes both rows on the same date
2024-01-01150250
2024-01-02200450
2024-01-03250700

Same query with ROWS frame:

sql4 lines
SELECT sale_date, amount,
       SUM(amount) OVER (ORDER BY sale_date
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM sales;

Result:

sale_dateamountrunning_total
2024-01-01100100← only the first row
2024-01-01150250← first + second
2024-01-02200450
2024-01-03250700

The ROWS version gives a per‑row running total; the RANGE version lumps all rows with the same date together.

Check yourself

Check your understanding of window frames.

1Multiple choiceChecked instantly

Which frame clause includes exactly the current row and the five rows immediately before it, regardless of their values?

  • AROWS BETWEEN 5 PRECEDING AND CURRENT ROW
  • BRANGE BETWEEN 5 PRECEDING AND CURRENT ROW
  • CROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  • DROWS BETWEEN CURRENT ROW AND 5 FOLLOWING
2Short answerMarked by AI

You have a table of employee salaries with duplicate values in the salary column. You compute a running total using SUM(salary) OVER (ORDER BY salary). The result shows that for some rows, the running total jumps by more than the current row's salary. Explain why this happens and which frame clause is causing it.

Graded on Identifies the default frame as RANGE · Explains the effect of duplicates on RANGE · Suggests a fix

3Numeric answerChecked instantly

A table has 10 rows. The current row is row 5 (1-indexed). How many rows are included in the frame ROWS BETWEEN 3 PRECEDING AND 2 FOLLOWING?

4Short answerMarked by AI

Which statement correctly distinguishes ROWS and RANGE?

Lesson 49

Window vs GROUP BY

Recognise when to use a window function instead of GROUP BY. Window functions retain individual rows while performing aggregate calculations.

10 min3 assessments

GROUP BY is great for summaries — one row per department, one row per month. But sometimes you need the summary and the rows that produced it. You could write a self-join or a subquery, but SQL gives you a cleaner tool: window functions. The choice comes down to whether you want to keep the original rows or throw them away.

Here is how GROUP BY and window functions differ on the axes that actually matter when you're deciding which to use.

GROUP BY

Preserves original rows?
No — collapses each group into one row
Can include non-aggregated columns directly?
No — every column must be in GROUP BY or an aggregate
Can filter on the aggregate result?
Yes, using HAVING
Result row count
One per group (fewer than input)

Best for When you only need per-group summary rows (e.g., 'total sales per month')

Window function

Preserves original rows?
Yes — each input row appears in the output
Can include non-aggregated columns directly?
Yes — you can mix window aggregates with any original column
Can filter on the aggregate result?
Indirectly — wrap in a subquery or CTE and filter there
Result row count
Same as input (if no other filtering)

Best for When you need the group–wise calculation beside each detail row (e.g., 'each sale plus that month's total')

Department average alongside each employee

Table employees:

idnamedepartmentsalary
1AdaEngineering120000
2GraceEngineering110000
3AlanMarketing95000

Goal: Show each employee's name, salary, and the average salary of their department.


sql7 lines
SELECT e.name, e.salary, d.avg_dept_salary
FROM employees e
JOIN (
  SELECT department, AVG(salary) AS avg_dept_salary
  FROM employees
  GROUP BY department
) d ON e.department = d.department;

Result:

namesalaryavg_dept_salary
Ada120000115000.00
Grace110000115000.00
Alan9500095000.00

sql3 lines
SELECT name, salary,
       AVG(salary) OVER (PARTITION BY department) AS avg_dept_salary
FROM employees;

Exact same result, same average values, but the query is shorter and preserves every original row without an extra join.

Common misconception

“GROUP BY and window functions do the same thing, so I can always use GROUP BY.”

Both can compute averages, counts, and sums per group. If you only ever look at the aggregate value, GROUP BY is simpler.

What is actually true

The two tools serve different output shapes. GROUP BY throws away the detail rows forever; window functions keep them and add the aggregate as a new column. If the final result must list each transaction, order, or employee individually, you need a window function (or a clunky join).

Decide

You run a small bookstore. Your sales table looks like this:

sale_idbook_titlegenrepricesale_date
1SQL for AnalystsReference40.002024-03-01
2The MartianFiction15.002024-03-01
3Brief HistoryScience20.002024-03-02
4SQL for AnalystsReference40.002024-03-02

You need the report described in each step.

Your boss wants: 'For every sale, show the sale price and the total revenue for that same genre on that same day.' Which approach do you reach for?

Check yourself

Check your understanding of when to use each approach.

1Select all that applyChecked instantly

Which of the following are valid reasons to use a window function instead of GROUP BY? (Select all that apply.)

  • AYou need to keep every original row in the result.
  • BYou want to calculate an aggregate that depends on values from multiple groups.
  • CYou want to show each row alongside an aggregate computed over a subgroup (e.g., department average).
  • DYou want to filter rows based on an aggregate value.
  • EYou want to avoid writing a self-join or subquery.
2Short answerMarked by AI

Table transactions has columns transaction_id, amount, store_id, date. Write a query that shows every transaction with its amount and the total revenue for that store on that date. Briefly explain why you chose GROUP BY or a window function.

Graded on Correct SQL structure · Reasoning about row preservation · Answer completeness

3Multiple choiceChecked instantly

If you use GROUP BY on a table with 100 rows and 10 groups, how many rows will the result have?

  • A100 rows (all original rows preserved)
  • B10 rows (one per group)
  • CBetween 10 and 100, depending on the aggregate
  • D110 rows (original plus totals)

Lesson 50

CASE WHEN: conditional logic in a query

You will be able to write a CASE expression to create calculated columns that depend on row values, exactly like a spreadsheet IF or IFS. Covers simple and searched CASE, and nesting. Prevents writing multiple UNION queries where one CASE would do.

20 min3 assessments

You already know how to filter rows with WHERE and group them with GROUP BY. But what if you need a column whose value depends on other values in the same row — like a spreadsheet IF that says "if the order amount is over 100, label it 'High', otherwise 'Low'"?

In SQL, that's the CASE expression. It lives inside a SELECT and produces a value for each row based on conditions you write. There are two forms:

  • Simple CASE compares one expression to a list of fixed values (like a spreadsheet SWITCH).
  • Searched CASE evaluates a list of Boolean conditions and returns the result of the first true one (like nested IFs).

Both forms evaluate conditions in order and stop at the first match. If no condition matches and there is no ELSE, the result is NULL.

You'll use CASE to avoid writing multiple UNION queries or awkward application logic. It's the cleanest way to add conditional columns.

Steps
0/5

Write a query that adds a category column to orders based on the amount. We'll use the searched CASE form.

  1. Start with the base query

    sql2 lines
    SELECT order_id, amount
    FROM orders;

    Don't forget the comma before adding CASE.

    Run it. You should see the raw order_id and amount columns.

  2. Add CASE keyword

    Add a comma after amount, then write CASE on a new line.

    CASE is an expression, not a statement. It must be inside SELECT.

    No error yet — the query is incomplete but valid if you add END later.

  3. Write the first WHEN condition

    sql2 lines
    CASE
      WHEN amount < 50 THEN 'Small'

    The condition amount < 50 is a Boolean expression. If true, the result is 'Small'.

    Use single quotes for string results, not double quotes (double quotes are for identifiers).

    The query still runs? Not yet — you need END.

  4. Add more WHEN clauses and an ELSE

    sql2 lines
      WHEN amount BETWEEN 50 AND 200 THEN 'Medium'
      ELSE 'Large'

    The ELSE catches everything that didn't match earlier.

    Order matters: put the most specific conditions first. If you put `ELSE` before a WHEN, it will never reach that WHEN.

    The conditions cover all possible amounts.

  5. Close with END and give the column an alias

    sql1 line
    END AS category

    Now run the full query.

    Don't forget the AS alias — without it the column will have a long expression as its name.

    You should see three columns: order_id, amount, category.

Checklist
0/6

Writing a CASE expression

Keep this open while you write your CASE queries. Tick each item before running.

Exercise3 checks
sql

Write a searched CASE expression that categorises each order:

  • 'Small' if amount < 50
  • 'Medium' if amount BETWEEN 50 AND 200
  • 'Large' otherwise

Replace the -- TODO comment with your WHEN clauses.

Tables you can query

  • orders (order_id, amount)
starter code5 lines
SELECT order_id, amount,
  CASE
    -- TODO: write your WHEN conditions here
  END AS category
FROM orders;
  • CASE is an expression that returns a value per row, evaluated in order of WHEN clauses.
  • Simple CASE compares one expression to fixed values using equality; searched CASE uses arbitrary Boolean conditions.
  • The first WHEN that evaluates to true wins; subsequent WHENs are skipped.
  • ELSE is optional but recommended to avoid unexpected NULLs.
  • All result expressions in THEN must have compatible data types.
  • CASE can replace multiple UNION queries that differ only by a filter condition.
Go deeper

Watch the video for a quick visual walkthrough (2:30). Then bookmark the PostgreSQL docs for the official syntax and edge cases.

Check yourself

Check your understanding of CASE WHEN.

1Put in orderChecked instantly

Arrange these steps in the order they happen when a searched CASE expression is evaluated for a single row.

  • AEvaluate the first WHEN condition
  • BReturn the result from the first true WHEN
  • CIf no WHEN is true, return the ELSE value (or NULL)
  • DSkip remaining WHEN clauses
2Short answerMarked by AI

You have a table of products with a price column. You need to add a column price_tier that shows 'Budget' for prices under 10, 'Standard' for prices 10–50, and 'Premium' for prices over 50. You could write this with a searched CASE or with three UNION queries. Which approach is better and why? (2-4 sentences)

Graded on Identifies the better approach (CASE) and states a reason · Explains why UNION would be worse

Lesson 51

Simple CASE expression

Write a simple CASE that compares an expression to a set of fixed values. Equivalent to a spreadsheet SWITCH or IFS with exact matches.

10 min4 assessments
Steps
0/5

You already know how to write a CASE expression with boolean conditions. The simple CASE is a shorthand when you're comparing the same expression to multiple fixed values — like a spreadsheet SWITCH or a series of exact-match IFs. Instead of repeating the expression in each WHEN, you write it once after CASE.

  1. Identify the expression to compare

    Decide which column or expression you want to test. For example, in an orders table, you might want to test the status column. The expression must evaluate to a single value per row.

    Don't use an expression that changes between rows in a way you don't expect — simple CASE compares the same expression for every row.

    You have a clear column or expression in mind.

  2. Write CASE followed by that expression

    Start with CASE status (or your expression). Do not add a comparison operator — the simple CASE automatically uses equality (=) for each WHEN.

    If you write `CASE WHEN status = 'A' THEN ...` you're writing a searched CASE, not a simple CASE. The simple CASE omits the condition after WHEN.

    Your query now begins with `CASE <expression>`.

  3. Add WHEN value THEN result for each case

    For each fixed value you want to match, write WHEN value THEN result. The value must be a literal (string, number, etc.) that matches the data type of the expression. For example: WHEN 'shipped' THEN 'Shipped'.

    Values are case-sensitive in most databases. `WHEN 'Shipped'` will not match `'shipped'`.

    You have one WHEN for each expected value.

  4. Optionally include an ELSE clause

    If none of the WHEN values match, the CASE returns NULL by default. Add ELSE 'Other' (or your default) to handle unmatched values.

    Forgetting ELSE can cause NULLs in your output — check whether that's acceptable.

    You've decided whether ELSE is needed.

  5. Close with END

    Finish the CASE expression with END. You can give it an alias: END AS status_label.

    Don't forget END — it's a syntax error.

    Your CASE block ends with `END` and optionally an alias.

Checklist
0/6

Writing a simple CASE

Use this checklist while writing any simple CASE expression.

Categorizing order statuses

Given the orders table:

idstatus
1shipped
2pending
3cancelled
4shipped

Write a query that maps status codes to display labels:

sql8 lines
SELECT id,
       CASE status
           WHEN 'shipped' THEN 'Shipped'
           WHEN 'pending' THEN 'Pending'
           WHEN 'cancelled' THEN 'Cancelled'
           ELSE 'Unknown'
       END AS status_label
FROM orders;

Result:

idstatus_label
1Shipped
2Pending
3Cancelled
4Shipped

Note that the ELSE clause catches any unexpected status values.

Exercise4 checks
sql

Complete the query to assign each product to a group: 'A' → 'Premium', 'B' → 'Standard', 'C' → 'Budget'. For any other category, use 'Other'. Give the CASE expression an alias category_group.

Tables you can query

  • products (id, category)
starter code5 lines
SELECT id,
       CASE category
           -- TODO: add WHEN clauses
       END AS category_group
FROM products;
  • YouTube

    Simple Case Statement

    Watch a live walkthrough of writing a simple CASE expression in a query editor, including common pitfalls.

    Mike·4:28·en

    Open
  • Simple CASE compares one expression to fixed values using equality.
  • Write CASE expression, then WHEN value THEN result for each case.
  • ELSE provides a default; without ELSE, unmatched rows return NULL.
  • Values are case-sensitive and must match the expression's data type.
  • Simple CASE is syntactic sugar for a searched CASE with repeated equality checks.
Check yourself

Test your understanding of the simple CASE expression.

1Put in orderChecked instantly

Arrange the steps to write a simple CASE expression in the correct order.

  • AWrite CASE followed by the expression
  • BAdd WHEN value THEN result for each case
  • CClose with END and optional alias
  • DOptionally add ELSE default clause
  • EIdentify the expression to compare
2Short answerMarked by AI

Consider the following query:

sql7 lines
SELECT name,
       CASE department
           WHEN 'Sales' THEN 'Revenue'
           WHEN 'Engineering' THEN 'Product'
           ELSE 'Support'
       END AS team
FROM employees;

The employees table has a row with department = 'sales' (lowercase). What will the team column be for that row? Explain why.

Graded on Identifies the correct output ('Support') · Explains the case-sensitivity issue

3Multiple choiceChecked instantly

Which of the following is a valid simple CASE expression?

  • ACASE WHEN status = 'A' THEN 'Active' WHEN status = 'I' THEN 'Inactive' END
  • BCASE status WHEN 'A' THEN 'Active' WHEN 'I' THEN 'Inactive' END
  • CCASE status = 'A' THEN 'Active' WHEN 'I' THEN 'Inactive' END
  • DCASE status WHEN 'A' THEN 'Active' ELSE 'Inactive' END CASE

Lesson 52

Searched CASE expression

Write a searched CASE with Boolean conditions. Each WHEN clause contains a condition, and the first true condition returns its result.

10 min3 assessments

In a spreadsheet, you use nested IF or IFS to check multiple conditions and return a value based on the first true one. SQL's searched CASE does the same thing: it evaluates a list of Boolean conditions in order and returns the result of the first WHEN clause whose condition is true.

Unlike a simple CASE, which compares a single expression to a list of values (CASE column WHEN 1 THEN ...), a searched CASE lets you write any Boolean expression in each WHEN clause. This means you can use comparisons, IS NULL, LIKE, or even subqueries.

sql9 lines
SELECT
  product,
  price,
  CASE
    WHEN price > 100 THEN 'Expensive'
    WHEN price > 50  THEN 'Moderate'
    ELSE 'Cheap'
  END AS price_category
FROM products;

The conditions are checked top‑to‑bottom. The first one that matches wins — so order matters. If a row has a price of 120, it gets 'Expensive', not 'Moderate'.

Steps
0/5

Write a searched CASE expression in a SELECT query. We'll use an orders table with columns amount and region.

  1. Start with CASE

    Type CASE (no expression after it — searched CASE has nothing between CASE and the first WHEN).

    If you put a column name after CASE, you switch to simple CASE mode and conditions must be equality checks.

    Your query begins with `SELECT ..., CASE`.

  2. Write the first WHEN clause

    Type WHEN condition THEN result. The condition must be a Boolean expression. Example: WHEN amount > 1000 THEN 'High'.

    Don't forget the `THEN` keyword. A missing `THEN` causes a syntax error.

    Run the query without the rest of the CASE to see if the condition syntax is valid (you'll get an incomplete CASE error, but no syntax error).

  3. Add more WHEN clauses

    Continue with additional WHEN condition THEN result lines. Each condition is independent. Example: WHEN amount > 500 THEN 'Medium'.

    Do **not** put a comma after a WHEN clause. SQL does not use commas inside CASE.

    The query still parses without syntax errors.

  4. Add an ELSE clause (optional)

    Type ELSE result to catch any rows that didn't match any WHEN. Example: ELSE 'Low'.

    If you omit ELSE and no WHEN matches, the CASE returns NULL. That may be fine, but be explicit if you want a default.

    Run the query with a few sample rows to confirm the ELSE value appears where expected.

  5. Close with END and give an alias

    Type END AS column_alias. The alias is how you refer to the calculated column in the result set or in an ORDER BY.

    Forgetting `END` causes an incomplete CASE error. Forgetting the alias leaves the column with a long expression as its name.

    Execute the query and verify the output column has the alias you chose and contains the expected categories.

Checklist
0/6

Searched CASE Checklist

Keep this checklist open while writing your query. Tick each item before running.

Categorising orders by amount

Table: orders

idamountregion
11200US
2750EU
3300APAC
450US

Query:

sql9 lines
SELECT
  id,
  amount,
  CASE
    WHEN amount > 1000 THEN 'High'
    WHEN amount > 500  THEN 'Medium'
    ELSE 'Low'
  END AS order_tier
FROM orders;

Result:

idamountorder_tier
11200High
2750Medium
3300Low
450Low

Notice that order 3 (300) matches neither > 1000 nor > 500, so it falls to ELSE 'Low'. If we had swapped the conditions, WHEN amount > 500 THEN 'Medium' first, then order 1 (1200) would incorrectly get 'Medium'.

Exercise3 checks
sql

Complete the searched CASE expression to assign a price tier:

  • 'Budget' if price < 20
  • 'Standard' if price < 100
  • 'Premium' otherwise

Use the alias price_tier. The result should have three columns: name, price, price_tier.

Tables you can query

  • products (product_id, name, price)
starter code7 lines
SELECT
  name,
  price,
  CASE
    -- TODO: add WHEN clauses
  END AS price_tier
FROM products;
  • A searched CASE starts with CASE (no expression after it) and contains WHEN condition THEN result clauses.
  • Conditions are Boolean expressions evaluated in order; the first true condition wins.
  • Order your WHEN clauses from most specific to most general to avoid unintended matches.
  • Use ELSE to provide a default value when no condition matches; without it the result is NULL.
  • Close the expression with END and give the column an alias for readability.
  • Do not put commas between WHEN clauses — they cause syntax errors.
Check yourself

Test your understanding of the searched CASE expression.

1Put in orderChecked instantly

Arrange these parts in the correct order for a complete searched CASE expression (assume one WHEN clause and an ELSE).

  • ACASE
  • BWHEN condition THEN result
  • CELSE result
  • DEND
2Short answerMarked by AI

A table scores has a column score. A colleague writes this query:

sql9 lines
SELECT
  student,
  score,
  CASE
    WHEN score >= 80 THEN 'B'
    WHEN score >= 90 THEN 'A'
    ELSE 'C'
  END AS grade
FROM scores;

A student with a score of 95 gets grade 'B' instead of 'A'. Explain why this happens and how to fix the query.

Graded on Identifies the order issue · Proposes correct fix · Explains first-match rule

  • YouTube

    SQL tutorial 50: CASE - Searched Case Expression In Oracle (2/2)

    Watch a quick walkthrough of the searched CASE syntax in action, with a live demo in Oracle SQL. Pay attention to the order of conditions and the use of ELSE.

    Manish Sharma·5:52·en

    Open

Lesson 53

Nesting CASE

Place one CASE expression inside another to handle multi-level conditional logic. Keep nesting shallow for readability.

10 min1 assessment

You already know that CASE WHEN gives you a single calculated column based on a condition. But what if the value you want to return depends on another condition that only matters when the first condition is true? Writing a second CASE inside the THEN of the first gives you two-level logic without giving up readability.

Spreadsheet users: this is like nesting an IF inside another IF – but with clearer syntax. The outer CASE evaluates first; when it reaches the THEN that contains the inner CASE, that inner expression runs on the same row.

Keep nesting to two levels in practice. Three levels are hard to read and almost always better rewritten as a join or a lookup table.

Steps
0/3

You need to assign a priority to orders based on customer region and order amount. The rule: PM customers get priority 1 if amount > 500, else priority 2; EM customers always get priority 3; others get priority 4. A nested CASE does this in one expression.

  1. Write the outer CASE on the first condition

    sql8 lines
    SELECT order_id, customer_region, amount,
      CASE
        WHEN customer_region = 'PM' THEN
          -- inner CASE will go here
        ELSE
          -- fallback for all other regions
      END AS priority
    FROM orders;```

    Do not forget the `ELSE` clause for the outer CASE – rows that don't match any region get NULL.

    The outer CASE runs without error and returns placeholder values (NULL or a literal) for the inner branch.

  2. Insert the inner CASE in the THEN branch

    sql8 lines
    CASE
      WHEN customer_region = 'PM' THEN
        CASE
          WHEN amount > 500 THEN 1
          ELSE 2
        END
      ELSE 3
    END AS priority```

    The inner CASE must be a complete expression – including its own `END` keyword. A missing `END` causes a syntax error.

    The query compiles. Test with a row where `customer_region = 'PM'` and `amount > 500` – the result should be `1`.

  3. Add the outer ELSE as a fallback

    Replace the ELSE 3 with a second nested CASE if you need more granularity for other regions, or keep it as a single value. For this exercise we keep it as ELSE 4 for non-PM/non-EM regions.

    If you have multiple outer WHEN branches, each may contain its own inner CASE. Keep them consistent in structure.

    Run a query that returns all rows and verify the priority column matches the rules for each region.

Categorising employees by department and tenure

Schema: employees(id, name, department, years_of_service)

Goal: Assign a level:

  • Engineering: if years >= 5 → 'Senior', else 'Junior'
  • Sales: if years >= 3 → 'Senior', else 'Junior'
  • Other: 'General'

Query:

sql15 lines
SELECT name, department, years_of_service,
  CASE
    WHEN department = 'Engineering' THEN
      CASE
        WHEN years_of_service >= 5 THEN 'Senior'
        ELSE 'Junior'
      END
    WHEN department = 'Sales' THEN
      CASE
        WHEN years_of_service >= 3 THEN 'Senior'
        ELSE 'Junior'
      END
    ELSE 'General'
  END AS level
FROM employees;

Result for a few rows:

namedepartmentyears_of_servicelevel
AliceEngineering6Senior
BobEngineering2Junior
CarolSales3Senior
DaveMarketing10General

The inner CASE uses the outer row's years_of_service – no extra join needed.

Exercise4 checks
sql

Complete the query so that priority is:

  • For PM region: 1 if amount > 500, else 2
  • For EM region: always 3
  • For any other region: 4

Replace the -- TODO comments with a working nested CASE.

Tables you can query

  • orders (id, customer_region, amount, is_urgent)
starter code8 lines
SELECT id, customer_region, amount,
  CASE
    WHEN customer_region = 'PM' THEN
      -- TODO: write inner CASE
    ELSE
      -- TODO: fallback
  END AS priority
FROM orders;
Checklist
0/5

Checklist for writing a nested CASE

Keep this checklist open while you write your nested CASE query.

  • A CASE expression can be nested inside another CASE's THEN (or ELSE) branch.
  • Each CASE expression must have its own END keyword, and they pair with the nearest CASE.
  • Nesting depth should rarely exceed 2 levels; deeper logic is better handled via a join or a lookup table.
  • The outer CASE evaluates first; the inner CASE runs only when the outer branch is taken.
  • You can nest CASE inside different outer WHEN branches, each with independent conditions.
  • Test every combination of outer and inner conditions to verify correctness.
  • YouTube

    Let's Learn SQL! Lesson 99: Nested Case Statements and Order By Case Statements

    Watch a short walkthrough of nested CASE with real examples in SQL Server – pay attention to the ORDER BY CASE usage at the end.

    Coffing Data Warehousing·3:27·en

    Open

Lesson 54

CASE vs UNION

Choose CASE over multiple UNION queries when you need conditional columns or row transformations. CASE is often simpler and faster.

10 min4 assessments

Tagging orders by amount — two ways

Your orders table has 1000 rows with columns id, customer, total. You need a report that labels each row as 'small' for totals under 50, 'medium' for 50–200, and 'large' for above 200.

UNION approach — three queries, three table scans:

sql8 lines
SELECT id, customer, total, 'small' AS size
FROM orders WHERE total < 50
UNION ALL
SELECT id, customer, total, 'medium' AS size
FROM orders WHERE total >= 50 AND total <= 200
UNION ALL
SELECT id, customer, total, 'large' AS size
FROM orders WHERE total > 200;

CASE approach — one query, one scan:

sql7 lines
SELECT id, customer, total,
  CASE
    WHEN total < 50 THEN 'small'
    WHEN total <= 200 THEN 'medium'
    ELSE 'large'
  END AS size
FROM orders;

Difference: With UNION the database reads orders three times (or the planner may be clever, but often not). With CASE it reads the table once and evaluates the expression per row. For 1000 rows the UNION runs in ~8 ms, the CASE in ~3 ms — the gap grows with the table size.

When UNION wins: If each branch pulls from a different table or has different WHERE filters on different columns, UNION may be the only readable choice. CASE works within one row only.

CASE and UNION both reshape rows into categories, but they work at different levels. Here is when each one fits:

CASE expression

Rows combined from
the current row only
Table scans per query
1 (one pass over the data)
Can cross tables?
No — works within one row's columns
Performance on large tables
Fast; grows linearly with row count

Best for Adding a calculated column based on values in the same row.

UNION (or UNION ALL) queries

Rows combined from
potentially different queries or tables
Table scans per query
N (one pass per SELECT)
Can cross tables?
Yes — each SELECT can target a different table
Performance on large tables
Slower on large data; may cause multiple scans

Best for Combining results from different sources or when the filtering condition uses different columns entirely.

Common misconception

“UNION is always faster because it's a set operator designed for performance, while CASE is just conditionals.”

UNION is a standard SQL set operation taught early; CASE looks like a procedural 'if' inside SQL. Many assume set operations are optimised and 'if' is slow.

What is actually true

UNION forces the database to scan each SELECT separately (often multiple passes over the same table), then deduplicate for plain UNION. A single CASE expression passes over the data once. For row-level labels, CASE is almost always faster and leaner.

You already know how to write a CASE expression — it's the SQL equivalent of a nested IF in a spreadsheet. Now the question is: when should you reach for CASE instead of writing several queries and gluing them together with UNION?

Rule of thumb: If every branch's result comes from the same row — based on that row's columns — use CASE. If each branch needs different columns or different tables, use UNION.

CASE evaluates row by row in a single pass. UNION runs two or more SELECT statements and combines their outputs. That difference matters at scale: a CASE on a million-row table scans the table once; a UNION of three queries may scan it three times.

Watch for the deduplication trap: UNION (without ALL) removes duplicate rows, which costs extra sorting. UNION ALL keeps duplicates and is cheaper. If you only need CASE, you pay zero deduplication cost.

Practical test: Write the query both ways on your actual dataset and look at the execution plan. In PostgreSQL, EXPLAIN ANALYZE will show you the difference in rows examined and total cost. Let the numbers decide.

Check yourself

Check that you can pick the right tool.

1Multiple choiceChecked instantly

You have an employees table. You need a column status that says 'manager' if job_title LIKE '%Manager%', 'executive' if title LIKE '%VP%', and 'staff' otherwise. Which construct should you use?

  • AUNION ALL with three SELECTs filtering by job_title
  • BCASE expression in the SELECT clause
  • CA subquery that unions two results then filters
  • DCROSS JOIN with a lookup table
2Fill the blanksChecked instantly
Check yourself
1Short answerMarked by AI

You have a sales table with columns region and revenue. You need a query that tags each row as 'domestic' if region = 'US' and 'international' otherwise. Why would you choose CASE over UNION for this?

Graded on Performance reason (single scan vs multiple scans) · Simplicity / readability

2Select all that applyChecked instantly

Which of the following situations would make UNION a better choice than CASE? Select all that apply.

  • AYou need to combine sales from a `usa_orders` table and a `eu_orders` table into one result set.
  • BYou want to add a 'discount_code' column whose value depends on the `total` column in the same row.
  • CYou are joining two tables that share no common columns, so you stack rows from each.
  • DYou need to label each product as 'low_stock' or 'in_stock' based on its `quantity` in the `inventory` table.

Lesson 55

Why a query is slow: a first look at indexes

You will be able to read a simple EXPLAIN plan, spot a sequential scan, and say what an index does. Covers the B-tree index, the primary key index, and the trade-off between read speed and write speed. Prevents adding indexes blindly.

30 min4 assessments
Decide

Your team's web dashboard is loading slowly. The culprit is a query that filters the orders table by a non-key column. You need to diagnose and fix it.

SELECT * FROM orders WHERE status = 'pending';

The table has 500,000 rows; only about 10,000 match the filter.

The query takes 8 seconds. What is your first action?

Decision tree for diagnosing a slow query using EXPLAIN

EXPLAIN output before and after adding an index

Before index (sequential scan):

code5 lines
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
                              QUERY PLAN
--------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..9000.00 rows=500000 width=100)
   Filter: (status = 'pending'::text)
  • Seq Scan – reads every row of the table.
  • cost=0.00..9000.00 – estimated cost; second number is total cost.
  • rows=500000 – estimated rows scanned (entire table).

After creating index on status:

sql1 line
CREATE INDEX idx_orders_status ON orders (status);
code5 lines
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
                                   QUERY PLAN
--------------------------------------------------------------------
 Index Scan using idx_orders_status on orders  (cost=0.00..200.00 rows=10000 width=100)
   Index Cond: (status = 'pending'::text)
  • Index Scan – uses the B-tree index to jump directly to matching rows.
  • rows=10000 – exactly the 2% that match.
  • Cost dropped from 9000 to 200.

The actual improvement: from 8 seconds to 0.2 seconds on a typical setup.

  • EXPLAIN shows the query plan, including scan type (Seq Scan or Index Scan) and estimated cost.
  • A sequential scan reads all rows; it's the default when no applicable index exists or when the table is small.
  • A B-tree index (default type) speeds up equality and range lookups on the indexed column.
  • A primary key automatically creates a unique B-tree index on that column.
  • Indexes slow down INSERT, UPDATE, and DELETE because the index must be updated with each write.
  • Read-heavy workloads (reporting, dashboards) benefit from indexes; write-heavy workloads need careful evaluation.
  • Always run EXPLAIN before adding an index – the plan tells you if the index would help.
Common misconception

“Adding an index always makes queries faster.”

Documentation and tutorials emphasize indexes for performance, and in many examples the query speed jumps dramatically after adding an index.

What is actually true

Indexes only speed up queries that retrieve a small fraction of rows (e.g., <5–10%). If you're fetching most of the table, a sequential scan is faster because an index scan adds random I/O. Also, indexes introduce write overhead, so they can harm overall performance in write-heavy systems.

Check yourself

Test your understanding of slow queries and indexes.

1Select all that applyChecked instantly

Which of the following are true about indexes? (Select all that apply.)

  • AThey can speed up SELECT queries that filter on indexed columns.
  • BThey always slow down INSERT operations.
  • CA primary key automatically creates an index.
  • DIndexes are always stored as B-trees in PostgreSQL (even if you don't specify).
  • EAn index can be used for columns that appear in JOIN conditions.
2Put in orderChecked instantly

Place these steps in the correct order for diagnosing a slow query.

  • AAdd an index if needed.
  • BRun EXPLAIN before the query.
  • CWrite the query.
  • DCheck which columns appear in WHERE / JOIN.
  • EExamine the plan for scan type and cost.
  • FDecide whether an index is worth its write overhead.
3Multiple choiceChecked instantly

You have a table users with 1 million rows. You run SELECT * FROM users WHERE city = 'Berlin'. It takes 5 seconds. Only 0.5% of rows have city = 'Berlin'. What should you do first?

  • AAdd an index on the `city` column.
  • BRewrite the query to use CASE WHEN.
  • CRun EXPLAIN to see the current query plan.
  • DDrop the table and recreate it.
4Short answerMarked by AI

Given the following EXPLAIN output:

code2 lines
Seq Scan on products  (cost=0.00..3450.00 rows=100000 width=100)
   Filter: (price > 100)
  1. 1.What type of scan is being performed?
  2. 2.Why is the optimizer using this scan type?

Graded on Identifies scan type correctly · Explains why the optimizer chose it

Lesson 56

Reading EXPLAIN output

Run EXPLAIN on a query and interpret the output. Identify the query plan, cost estimates, and operations like sequential scan or index scan.

15 min4 assessments

A slow query is a mystery until you can see how the database decided to run it. EXPLAIN prints the query plan: the operations the database will perform, in the order data flows upward.

In "Why a query is slow" you learned what an index is for. Here is the payoff: EXPLAIN shows whether a specific query actually used one. Look for the scan nodes — the lines that touch a table. Seq Scan on users (or SCAN users in SQLite) means the whole table was read top to bottom. Index Scan using ... on users (or SEARCH users USING INDEX ...) means the index was walked instead.

How to run it:

  • PostgreSQL: EXPLAIN SELECT ...;
  • SQLite: EXPLAIN QUERY PLAN SELECT ...;

The plan is a tree. Leaf nodes read from tables; the nodes above combine, filter or sort those rows. Plain EXPLAIN does not run the query, so it is safe on slow statements.

PostgreSQL also prints cost estimates on each line, like cost=0.00..1420.37 rows=120000. The two numbers are startup cost and total cost. They are measured in arbitrary planner units, not milliseconds — use them to compare plans, not to predict wall-clock time. SQLite's EXPLAIN QUERY PLAN skips costs and gives one detail row per plan step, which is often enough to answer the only question that matters: index or full scan.

Steps
0/6

Read any plan in this order. Jumping to the cost numbers before you have found the scans is how people conclude the wrong node is slow.

  1. Run EXPLAIN on the exact query

    Run EXPLAIN SELECT ...; in PostgreSQL, or EXPLAIN QUERY PLAN SELECT ...; in SQLite. Keep the WHERE clause identical to the query you are investigating.

    Do not add `ANALYZE` yet. Plain `EXPLAIN` only plans; `EXPLAIN ANALYZE` also executes the query.

    Output shows plan operators, not your result rows.

  2. Find every scan node

    Look for the lowest lines that name a table. PostgreSQL prints ... on customers; SQLite prints SCAN customers or SEARCH customers in the detail column. Every table in the query should appear in exactly one scan node.

    Lines like `Hash`, `Sort` and `Nested Loop` are not scans — they sit above the scans and rearrange rows.

    You can point to one scan node per table.

  3. Classify each scan

    Classify each scan node. Seq Scan/SCAN = full table read. Index Scan, Index Only Scan, or SEARCH ... USING INDEX = index used.

    An `Index Scan` still visits the table row after finding each index entry; an `Index Only Scan` avoids that only when every needed column is in the index.

    For every table you can say: full scan or index lookup.

  4. Read the condition attached to the scan

    PostgreSQL prints Index Cond: or Filter:; SQLite appends (email=?) to the SEARCH detail. It should match the column in your WHERE clause.

    If the WHERE clause wraps the column in a function, e.g. `WHERE lower(email) = ...`, the index cannot be used and the plan will not show an index condition.

    The condition names the same column you filtered on.

  5. Read cost and row estimates

    PostgreSQL prints cost=startup..total and rows=N. Compare these numbers between the same node before and after you change an index or a query. Smaller total usually means cheaper plan.

    Cost is not a time. `cost=0.00..1943.10` does not mean 1,943 ms. SQLite's `EXPLAIN QUERY PLAN` omits cost, so don't go looking for it there.

    You can say which number is startup and which is total.

  6. Confirm with EXPLAIN ANALYZE when you need actual time

    When actual timing matters, run EXPLAIN ANALYZE SELECT ...; in PostgreSQL. It adds actual time and actual rows beside the estimates.

    This executes the statement. Never use it on a query you cannot afford to run to completion, and never on one that writes data.

    Output now pairs each `cost` estimate with an `actual time` value.

Two plans for one email lookup

Query:

sql1 line
SELECT name FROM customers WHERE email = '[email protected]';

PostgreSQL, with an index on customers.email:

text2 lines
Index Scan using idx_customers_email on customers  (cost=0.28..8.30 rows=1 width=12)
  Index Cond: (email = '[email protected]'::text)

What to notice:

  • Index Scan using ... — the B-tree index was used.
  • Index Cond — the index served exactly the WHERE condition.
  • 0.28..8.30 — 0.28 startup, 8.30 total cost units; not milliseconds.
  • rows=1 — the planner expects one row to come out.

Same query without the index:

text2 lines
Seq Scan on customers  (cost=0.00..12.30 rows=100 width=12)
  Filter: (email = '[email protected]'::text)

Seq Scan is the tell: every row is read before the filter is applied.

SQLite prints the same two cases as:

text2 lines
id  parent  notused  detail
0   0       0        SEARCH customers USING INDEX idx_customers_email (email=?)

and, without the index:

text1 line
SCAN customers
Checklist
0/6

EXPLAIN reading checklist

Keep this open while you work. When the code exercise fails, one of these checks is the reason.

Exercise2 checks
sql

Turn the starter SELECT into a plan-reading statement. Your final SQL must begin with EXPLAIN QUERY PLAN and leave the WHERE clause unchanged. Run it and confirm that the detail column says SEARCH customers USING INDEX idx_customers_email (email=?) — that is the proof the index was used.

Tables you can query

  • customers (id, email, name)
starter code1 line
SELECT name FROM customers WHERE email = '[email protected]';
Check yourself

Three quick checks before you run EXPLAIN on your own queries.

1Put in orderChecked instantly

These steps for reading an EXPLAIN plan are scrambled. Put them in the order you would actually perform them when a plan appears on screen.

  • ARun EXPLAIN on the exact slow query.
  • BClassify each scan as Seq/SCAN or Index/SEARCH.
  • CRead the cost and row estimates on each scan.
  • DFind the scan node for every table in the query.
  • EConfirm with EXPLAIN ANALYZE if you need actual time.
2Short answerMarked by AI

A colleague shows you this plan for SELECT * FROM products WHERE category = 'tools'; and says the index on category is broken because the plan says Seq Scan on products:

text2 lines
Seq Scan on products  (cost=0.00..42.10 rows=340 width=40)
  Filter: (category = 'tools'::text)

Is that conclusion justified? Explain in 2–4 sentences what Seq Scan actually means, and name one reason the planner might legitimately choose it even when the index exists.

Graded on Defines what Seq Scan means · Names a legitimate reason for choosing a sequential scan · Suggests a concrete next step

3Multiple choiceChecked instantly

Plain EXPLAIN gives you this line for a query:

Seq Scan on orders (cost=0.00..1943.10 rows=120000 width=16)

Which reading of the cost is correct?

  • AThe query will take about 1,943 milliseconds.
  • B0.00 is startup cost and 1943.10 is total estimated work in arbitrary planner units, not wall-clock time.
  • CThe query will return 1,943 rows; 0.00 means no startup cost was estimated.
  • DThe scan starts after 1,943 units of work, so the plan is high-latency.

Lesson 57

Sequential scan vs index scan

Understand the difference between a sequential scan (reading entire table) and an index scan (using an index to locate rows). Recognise when each occurs.

10 min4 assessments

When you run a SELECT query, the database has two fundamental ways to fetch rows: sequential scan and index scan. A sequential scan reads every row in the table, one by one, from start to finish. It's thorough but expensive — even if only one row matches, the database still touches every row. An index scan uses a pre-built B-tree (or other structure) to jump directly to the rows that match your WHERE clause. The database first looks up the key in the index, finds the pointer to the row’s location on disk, and reads only that row.

Which one the query planner picks depends on selectivity — how many rows you’re likely to retrieve. If the predicate matches a small fraction of the table (say, less than 5–10%), an index scan is faster. If it matches many rows, a sequential scan is often faster because reading the whole table in one go is cheaper than many random individual reads.

You’ve already seen EXPLAIN in the previous node. Here, the planner’s decision is the key: it estimates the cost of both strategies and chooses the cheaper one.

Decision path for scan type in the query planner.

Analogy

Imagine finding a specific word in a thick book. Sequential scan is like reading every page until you spot the word. It works for any word, but takes forever if the book is huge. Index scan is like using the index at the back — you look up the word, get page numbers, and flip directly to those pages. It’s much faster when you’re looking for a rare word.

Breaks down when: The index is outdated or when you need most of the words in the book (low selectivity). If you look up a word that appears on every page, the index still gives you every page number, and then you have to flip to each page separately — slower than just reading the whole book cover to cover.

Where the analogy breaks down

When the query matches a large fraction of rows (high selectivity), the index scan causes many random page reads, which is slower than a single sequential pass through the entire table.

Comparing scans on an orders table

Suppose table orders has 1,000,000 rows. A query WHERE customer_id = 99 matches 1,234 rows (0.12% selectivity).

  • With a B-tree index on customer_id: The planner estimates the index scan cost as ~200 random I/Os (1,234 index lookups + row fetches). Sequential scan would cost 1,000,000 sequential I/Os. Index scan wins, and EXPLAIN shows Index Scan.
  • Without an index on customer_id: The planner has no choice — it must do a sequential scan, even for a rare match.
  • If the same query matches 800,000 rows (80% selectivity): Index scan would require ~800,000 random I/Os, while sequential scan reads 1,000,000 rows in one pass (much cheaper). The planner picks sequential scan.

Execution time difference: 0.12% selectivity → index scan often finishes in milliseconds; sequential scan for the whole table might take several seconds.

Common misconception

“An index always makes a query faster, so I should index every column I query.”

When you first learn about indexes, you see dramatic speedups: a query that took 10 seconds runs in 0.1 second after adding an index. It's easy to think indexes are always beneficial.

What is actually true

Indexes speed up queries that return a small percentage of rows, but they slow down writes (INSERT/UPDATE/DELETE) and can even hurt read performance if the planner chooses an index scan for a large portion of rows. For a small table (say <1,000 rows), a sequential scan may be faster than an index scan because the overhead of traversing the B-tree and random reads outweighs the cost of a full table read. The planner considers table size, selectivity, and index structure before deciding.

Check yourself

Check your understanding of when the planner chooses each scan type.

1Multiple choiceChecked instantly

Given a table customers with 500 rows and a non-indexed column signup_date, which query is most likely to be executed as a sequential scan?

  • ASELECT * FROM customers WHERE signup_date = '2024-01-01' (expected matches: 2 rows)
  • BSELECT * FROM customers WHERE signup_date > '2023-01-01' (expected matches: 480 rows)
  • CSELECT * FROM customers WHERE signup_date = '2024-01-01' AND id = 123 (id has a primary key index)
  • DSELECT * FROM customers WHERE signup_date IS NULL (expected matches: 10 rows)
2Short answerMarked by AI

You run SELECT * FROM logs WHERE user_id = 42 on a table with 10,000,000 rows. There is no index on user_id. How many rows does the database have to read to answer the query? Explain briefly.

Graded on Correct number of rows read · Explanation of why

3Short answerMarked by AI

The following EXPLAIN output shows a query on employees table (300 rows) which has an index on department_id. The query is SELECT * FROM employees WHERE department_id = 5. The planner chose a sequential scan. Propose two plausible reasons why the planner might have preferred sequential over index scan.

Graded on First plausible reason · Second plausible reason

4EssayMarked by AI

You are tuning a query that joins a small table categories (50 rows) with a large table products (200,000 rows) on category_id. Both tables have indexes on category_id. The query currently does a sequential scan on both tables. Evaluate whether this is a problem. In your answer, discuss:

  • The size of each table and its impact on scan choice.
  • The role of selectivity (assume the join involves many categories).
  • When an index scan would be better, and when it wouldn't.

Finally, recommend whether you would add an index or leave the plan as is, and justify.

Graded on Assessment of 50-row table · Assessment of 200k-row table in join context · Consideration of when index would be better · Final recommendation and justification

Lesson 58

B-tree index structure

Learn the basics of a B-tree index: balanced tree, sorted keys, and how it speeds up equality and range lookups.

10 min4 assessments

You run SELECT * FROM employees WHERE salary = 50000 against a table with a million rows. Without an index, the database reads every row — a sequential scan. With an index, it finds the matching rows in a handful of reads. The index structure that makes this possible is the B-tree. A B-tree is a balanced tree that stores keys in sorted order. Each node can hold many keys and pointers to child nodes. The tree stays balanced: all leaf nodes are at the same depth, so any search visits the same number of nodes. This keeps the cost predictable and low — typically 2–4 node reads even for millions of rows.

  • YouTube

    B-trees in 4 minutes — Intro

    Watch the animation to see how a B-tree grows, splits, and how a search moves from root to leaf. The visual makes the balanced-tree concept concrete.

    Michael Sambol·3:57·en

    Open
Practice
0/8 known
1 / 8
Reference

B-tree Index Properties

Height

Description
Number of levels from root to leaf
Typical Value / Example
2–4 for 1 million rows with fan-out 100

Fan-out

Description
Maximum children per node
Typical Value / Example
Often 100–500

Keys per node

Description
Number of keys in an internal node (fan-out - 1)
Typical Value / Example
99–499

Leaf nodes

Description
Nodes at the bottom holding keys and row pointers
Typical Value / Example
All at same depth

Internal nodes

Description
Nodes between root and leaves; contain keys and child pointers
Typical Value / Example
Guide the search

Search cost

Description
Number of node reads to find a key
Typical Value / Example
Height + 1 (e.g., 3–5 reads)

Insert cost

Description
Node reads plus possible splits
Typical Value / Example
Height + 1 reads, plus split writes

Space overhead

Description
Extra storage for index vs. table
Typical Value / Example
Typically 10–20% of table size

Use this table as a quick reference when estimating index performance.

  • A B-tree is a balanced tree that stores keys in sorted order within each node.
  • All leaf nodes are at the same depth, guaranteeing logarithmic search time.
  • A single node holds many keys (fan-out often 100+), so the tree height stays low (2–4 for millions of rows).
  • Search proceeds from root to leaf, comparing keys at each node to choose the correct child.
  • Leaf nodes contain the actual key values and pointers to rows; internal nodes only guide the search.
  • Insertions may split a full node, promoting the middle key upward to keep the tree balanced.
  • B-tree indexes are automatically maintained by the database and are the default index type in most relational systems.
Check yourself

Check your understanding of B-tree index structure.

1Multiple choiceChecked instantly

Which of the following best describes a B-tree index?

  • AA sorted list of keys stored in a single file.
  • BA balanced tree where each node can have multiple children and keys.
  • CA hash table that maps keys to row locations.
  • DAn array of pointers to rows.
2Select all that applyChecked instantly

Which statements about B-tree indexes are true? (Select all that apply.)

  • AB-tree indexes are only useful for equality searches.
  • BA B-tree index can speed up range queries like WHERE salary BETWEEN 40000 AND 50000.
  • CAll leaf nodes are at the same depth.
  • DInserting a new key always requires splitting a node.
  • EB-tree indexes are automatically maintained by the database.
3Fill the blanksChecked instantly
4Fill the blanksChecked instantly

Lesson 59

Primary key index

Understand that a primary key automatically creates a unique index. It enforces uniqueness and speeds up lookups by primary key.

8 min4 assessments

You already know from the previous node that an index speeds up lookups by avoiding a full table scan. The primary key index is the most common index you'll encounter. When you define a PRIMARY KEY constraint on a column (or set of columns), the database automatically creates a unique index behind it. This index does two things: it enforces that every value in the primary key column is unique and not NULL, and it allows the database to find any row by its primary key in O(log n) time — much faster than scanning the whole table.

Because the index is created automatically, you never need to write a separate CREATE INDEX statement for your primary key. The index is always a B-tree (or similar structure), which is ideal for equality and range lookups. However, every insert, update, or delete on the table must also update the index, so there is a small write penalty — a trade-off you already saw in the previous node.

Reference

Primary Key Index Quick Reference

Automatically created

Detail
When you add a PRIMARY KEY constraint, the database creates a unique index without any extra command.

Uniqueness enforced

Detail
No two rows can have the same primary key value; NULLs are not allowed.

Index type

Detail
Typically a B-tree index, optimized for equality and range queries.

Lookup speed

Detail
O(log n) for exact match on the primary key; O(log n + k) for range queries.

Write impact

Detail
Each insert/update/delete must update the index, adding a small overhead.

Can be composite

Detail
A primary key can span multiple columns; the index is built on all of them together.

Cannot be dropped separately

Detail
Dropping the primary key constraint drops the index; you cannot keep the index without the constraint.

These facts apply to all major relational databases (PostgreSQL, MySQL, SQL Server, SQLite).

Practice
0/8 known
1 / 8
  • A PRIMARY KEY constraint automatically creates a unique B-tree index on the key column(s).
  • The index enforces uniqueness and non-NULL values on the primary key.
  • Lookups by primary key are O(log n) instead of O(n) without an index.
  • Every write operation (INSERT, UPDATE, DELETE) must update the index, adding overhead.
  • You cannot have a primary key without its index; dropping the constraint drops the index.
Check yourself

Check your understanding of primary key indexes.

1Multiple choiceChecked instantly

Which of the following is true about a primary key index?

  • AIt is created manually with a CREATE INDEX statement.
  • BIt is automatically created when you define a PRIMARY KEY constraint.
  • CIt allows NULL values in the indexed column.
  • DIt uses a hash table structure for faster lookups.
2Select all that applyChecked instantly

Select all statements that are true about primary key indexes. (More than one is correct.)

  • AA primary key index enforces uniqueness on the key column(s).
  • BA primary key index can be dropped without dropping the constraint.
  • CLookups by primary key are faster than without an index.
  • DA primary key index adds overhead to write operations.
  • EA primary key index is always a hash index.
3Fill the blanksChecked instantly
4Short answerMarked by AI

Consider a table with 10 million rows and no primary key. You frequently need to look up rows by a column that could serve as a natural primary key. What problems would you face in terms of data integrity and query performance? Explain how adding a primary key index would address each problem.

Graded on Identifies data integrity problem · Identifies performance problem · Explains how primary key index solves both

Lesson 60

Index trade-offs

Evaluate the trade-off between read performance (faster queries) and write performance (slower inserts/updates). Avoid adding indexes blindly.

12 min3 assessments
Common misconception

“Adding an index always makes the database faster.”

Indexes speed up SELECT queries dramatically, so more indexes must be better for performance.

What is actually true

Each index slows down INSERT, UPDATE, and DELETE operations because the index structure must be updated on every row change. On write-heavy tables, too many indexes can make the overall system slower and cause backlogs.

The choice to add an index depends on your workload. Here's how the options stack up.

Index on read-heavy column

SELECT speed
Fast
INSERT/UPDATE/DELETE speed
Moderate
Storage cost
Moderate
Maintenance overhead
Low

Best for Tables with frequent SELECT queries and rare writes (e.g., product catalog).

Index on write-heavy column

SELECT speed
Moderate
INSERT/UPDATE/DELETE speed
Slow
Storage cost
Moderate
Maintenance overhead
Moderate

Best for Tables with frequent writes and occasional SELECT queries (e.g., audit logs).

No index

SELECT speed
Slow (sequential scan)
INSERT/UPDATE/DELETE speed
Fast
Storage cost
None
Maintenance overhead
None

Best for Very small tables, or tables with extremely rare queries where a full scan is acceptable.

Orders table: before and after an index

Consider a table orders with 1 million rows, columns id, customer_id, total, created_at.

Without index on customer_id:

  • Query: SELECT * FROM orders WHERE customer_id = 42; → sequential scan, 200 ms.
  • Insert: INSERT INTO orders VALUES (...); → 1 ms (just write to table).

After adding index on customer_id:

  • Same query now uses the index → 2 ms (100× faster).
  • Insert now must update the index → 5 ms (5× slower).

Real impact: If you have 1,000 inserts per second, the index adds 4 ms per insert, totalling 4 seconds of extra work per second — causing a backlog. The read speed gain is only useful if the query volume justifies the write cost.

  • YouTube

    SQL indexing best practices | How to make your database FASTER!

    Watch a DBA demonstrate the exact trade-off on a live database: an index speeds up a query but measurably slows down an insert. Pay attention to the timing numbers.

    CockroachDB·4:08·en

    Open
  • Indexes speed up SELECT queries by replacing sequential scans with tree lookups.
  • Every index adds overhead on INSERT, UPDATE, and DELETE because the index must be kept in sync.
  • A write-heavy table (e.g., logs, events) can become slower overall with too many indexes.
  • Storage cost is proportional to the number of indexed columns and the size of the key values.
  • The decision to index depends on the ratio of reads to writes, not just the query speed gain.
  • Small tables (under a few thousand rows) rarely benefit from an index; a sequential scan is fast enough.
Check yourself

Check your understanding of index trade-offs.

1Select all that applyChecked instantly

Which of the following are costs of adding an index? (Select all that apply.)

  • ASlower SELECT queries
  • BSlower INSERT queries
  • CIncreased storage space
  • DSlower DELETE queries
  • EFaster UPDATE queries
2Short answerMarked by AI

You have a logging table that receives 10,000 INSERTs per second and is queried once a day for a report. Should you add an index on the timestamp column? Explain your reasoning in 2–3 sentences.

Graded on Recognizes the write-heavy pattern and recommends against index · Justifies with the trade-off principle

3Multiple choiceChecked instantly

What is the primary trade-off when adding an index to a database column?

  • AFaster reads vs slower writes
  • BFaster writes vs slower reads
  • CMore storage vs less storage
  • DSimpler queries vs more complex queries

Lesson 61

Capstone: build a business report from a raw dataset

You will take three unlabelled CSV files, load them into a database, and write the queries that produce a one-page summary report with rankings, year-over-year comparisons, and a top-ten list. Covers the full pipeline from raw data to formatted output. Prevents writing queries that work on sample data but fail on real volumes.

90 min4 assessments
  • Loading raw CSV files into a database requires defining a schema that matches the data types and constraints of each column.
  • Primary keys and foreign keys are enforced after import; insert order matters when tables reference each other.
  • Aggregating with GROUP BY and sorting with ORDER BY and LIMIT produces ranked lists and summaries without window functions.
  • Year-over-year comparisons can be built with self-joins or subqueries that group data by year and join on matching categories.
  • A one-page business report typically includes total sales, breakdowns by product and month, a top-performers list, and a YoY comparison.
Go deeper

Refer to the project SQL scripts in resource 434 for examples of schema creation, import, and sample queries. Video 433 demonstrates how to approach a data analytics case study step by step.

Project

Capstone: Build a Business Report from Raw Data

You are a data analyst at an e‑commerce company. You receive three unlabelled CSV files: sales.csv (sale_id, product_id, region_id, sale_amount, sale_date), products.csv (product_id, product_name, category), and regions.csv (region_id, region_name). Your task is to load these files into a SQLite database, define appropriate tables, and write queries that produce a one‑page summary report. The report must include:

  • Total sales per product (all time).
  • Total sales per month (any year).
  • Top 5 products by total sales.
  • Year‑over‑year comparison of total sales (e.g., sales in 2023 vs. 2024 per month or per product).
  • A plain‑text summary of the report you would present to management.

You may use any SQL dialect. Assume the CSV files are in the same directory as your database. Write all SQL statements in a single script. After execution, the script should print or return the report tables.

"Done" means your script can be run from scratch and produces all required outputs. Your deliverables are the complete SQL script and a short written report (up to 300 words).

Deliverables

  • 01Complete SQL script that creates tables, imports data (INSERT or COPY), and runs the five queries listed in the brief.
  • 02A written summary (200–300 words) that describes the report's findings, including total revenue, the top product, and a noticeable trend in the YoY comparison.
ProjectMarked by AI

Build a one-page business report from three CSV files. Deliver the SQL script and a written summary as described in the project brief. Submit your script and summary together.

Graded on Schema and data import · Total sales queries · Top 5 and YoY comparison · Written summary

Checklist
0/8

Pre-submission Checklist

Tick each item before submitting your script. The checklist covers the most common pitfalls in this project.

Check yourself

Check your understanding of the data pipeline before you start writing your script.

1Multiple choiceChecked instantly

When loading sales.csv which contains sale_date values like '2024-03-15', what is the best data type for that column?

  • ATEXT
  • BINTEGER
  • CREAL
  • DBLOB
2Short answerMarked by AI

You have loaded the three tables. Which of the following statements is true about the order of INSERT statements?

3Short answerMarked by AI

Which query correctly finds total sales per product using a JOIN?

Lesson 62

Import CSV files

Load three unlabelled CSV files into a database, define appropriate table schemas, handle data type mismatches, and verify row counts and column integrity.

15 min3 assessments

You have three CSV files from the business report: customers.csv, orders.csv, and products.csv. Opening them in a spreadsheet shows rows and columns, but you can't run a JOIN or GROUP BY there. To use SQL, you need to load each file into a database table with the right column names and data types. A wrong data type — like storing a date as text — will break every query that sorts or filters by that column. This lesson walks you through importing a CSV into SQLite, the database engine you're using, and catching the common mismatches before they poison your report.

Steps
0/4

You'll import one CSV file — customers.csv — into a table called customers. The same steps apply to the other two files.

  1. Inspect the CSV headers and first rows

    Open customers.csv in a text editor or spreadsheet. Note the header row: customer_id,first_name,last_name,email,registration_date. Also note that the date column uses YYYY-MM-DD format and some email cells are empty.

    If you skip this step, you'll guess column names and data types. Guessed types often fail on the first row that contains a non-numeric value in a numeric column.

    You can list the first 5 rows with `head -n 5 customers.csv` on the command line.

  2. Write the CREATE TABLE statement

    Based on the headers, write:

    sql7 lines
    CREATE TABLE customers (
        customer_id INTEGER PRIMARY KEY,
        first_name TEXT NOT NULL,
        last_name TEXT NOT NULL,
        email TEXT,
        registration_date TEXT
    );

    Date is stored as TEXT because SQLite has no native date type; you'll convert it later in queries.

    Forgetting `PRIMARY KEY` on `customer_id` will allow duplicate rows. Omitting `NOT NULL` on `first_name` lets empty strings slip in.

    Run `.tables` in the sqlite3 prompt to confirm `customers` appears.

  3. Import the CSV using the .import command

    In the sqlite3 shell, set the mode to CSV and import:

    code2 lines
    .mode csv
    .import customers.csv customers

    This reads the file row by row and inserts into the table. The first row (headers) is treated as data unless you skip it.

    The `.import` command treats the first row as data. If your CSV has headers, they will become a row in the table. To avoid this, either remove the header row from the CSV before importing, or create the table without the header row and then use `.import` with `--skip 1` (available in SQLite 3.32+).

    Run `SELECT COUNT(*) FROM customers;` — the count should equal the number of data rows in the CSV (total rows minus header).

  4. Verify row count and check for data type mismatches

    Count rows: SELECT COUNT(*) FROM customers;. Then check for unexpected NULLs or type errors:

    sql1 line
    SELECT COUNT(*) FROM customers WHERE email IS NULL;

    Also test that customer_id is truly integer:

    sql1 line
    SELECT customer_id, typeof(customer_id) FROM customers LIMIT 5;

    If typeof returns text for any row, the import treated numbers as strings — fix by re-creating the table with correct types and re-importing.

    A common mismatch: a column that looks numeric but contains a single non-numeric value (e.g., 'N/A') will cause the entire column to be imported as TEXT. Find the offending row with `SELECT * FROM customers WHERE typeof(customer_id) = 'text';`

    All rows show `integer` for `customer_id` and `NULL` counts match your expectations (e.g., 3 emails missing out of 100 rows).

Checklist
0/7

CSV Import Verification Checklist

Keep this list open while you work through the three CSV files. Tick each item after every import.

Exercise1 check
sql

After importing a CSV into the sales table, some rows may have missing values in the amount column. Write a query that returns the number of rows where amount is NULL. This helps you detect import problems like a column that was stored as text or a CSV with empty cells.

Tables you can query

  • sales (id, product, amount)
starter code1 line
SELECT COUNT(*) FROM sales WHERE amount IS NULL;
  • Always inspect the CSV header and a few data rows before writing a CREATE TABLE statement.
  • Match column names exactly between the CSV header and the table schema to avoid misaligned imports.
  • Choose data types that fit the actual values: use INTEGER for whole numbers, REAL for decimals, TEXT for strings and dates.
  • SQLite's .import command treats the first row as data; skip the header row with --skip 1 or remove it from the file.
  • After import, verify row count and use typeof() to catch columns that imported as text instead of numbers.
  • Check for unexpected NULLs in columns you expected to be filled; they often indicate empty cells in the CSV.
Go deeper
  • YouTube

    How to Import CSV File Into Excel

    ProgrammingKnowledge2·3:22·en

    Open
  • YouTube

    LibreOffice Base - Import a CSV File

    Computing in a Nutshell·8:56·en

    Open

These videos show CSV import in spreadsheet tools, which is conceptually similar to database import: you define column types and handle mismatches. Watch them to see the same principles applied in a familiar interface.

Check yourself

Check your understanding of the import process.

1Put in orderChecked instantly

Arrange these steps in the correct order for importing a CSV file into a SQLite database.

  • ARun SELECT COUNT(*) and typeof() checks to verify the import.
  • BWrite a CREATE TABLE statement with appropriate column names and data types.
  • CInspect the CSV file's header row and sample data.
  • DUse .mode csv and .import to load the file into the table.
  • EHandle any data type mismatches found (e.g., re-import with corrected types).
2Short answerMarked by AI

You import a CSV with a price column that contains values like 12.50, 9.99, and one cell that says N/A. After import, SELECT typeof(price) FROM table LIMIT 1 returns text instead of real. Explain why this happened and describe the steps you would take to fix the table so that price is stored as a numeric type.

Graded on Identifies the root cause · Describes the fix steps · Clarity and completeness

Lesson 63

Write ranking & comparison

Construct SQL queries for ranked lists, year-over-year comparisons, and a top-ten list using window functions and self-joins; test against sample data.

20 min3 assessments

In a spreadsheet, ranking means using RANK or sorting a column. Year-over-year comparison means writing a formula like =VLOOKUP(year-1) or using OFFSET. Both are manual and break when data changes.

In SQL, window functions do both in one query. ROW_NUMBER(), RANK(), and DENSE_RANK() assign ranks within groups. LAG() reaches back to the previous row without a self-join. This lesson walks you through the exact syntax and builds a report that ranks products by revenue and shows last year's revenue beside each row.

  • ROW_NUMBER() assigns unique sequential numbers within a partition; ties get different numbers.
  • RANK() gives the same number to ties and leaves gaps; DENSE_RANK() does not leave gaps.
  • LAG(column, offset, default) accesses a previous row's value without a self-join.
  • A self-join compares rows from the same table by matching year-1 to year.
  • PARTITION BY groups the ranking (e.g., rank within each year).
  • ORDER BY in the window function determines the ranking order (e.g., revenue DESC).

Rank products by revenue with year-over-year comparison

Table sales

yearproductrevenue
2022Widget100
2022Gadget150
2023Widget120
2023Gadget180
2023Doohickey90

Query

sql8 lines
SELECT
  year,
  product,
  revenue,
  RANK() OVER (PARTITION BY year ORDER BY revenue DESC) AS rank,
  LAG(revenue, 1, 0) OVER (PARTITION BY product ORDER BY year) AS prev_year_revenue
FROM sales
ORDER BY year, rank;

Result

yearproductrevenuerankprev_year_revenue
2022Gadget15010
2022Widget10020
2023Gadget1801150
2023Widget1202100
2023Doohickey9030

Notice: LAG is partitioned by product so it looks at the same product's previous year. The default 0 handles the first year where no previous row exists.

Steps
0/5

You need to produce a report that ranks departments by revenue within each year and shows the previous year's revenue for comparison. Follow these steps using the revenue table.

  1. Inspect the table structure

    Run PRAGMA table_info(revenue); or SELECT * FROM revenue LIMIT 5; to see columns: year, department, amount.

    Don't assume column names – check them first.

    You see three columns: year (INT), department (TEXT), amount (REAL).

  2. Write the ranking window function

    Start with SELECT year, department, amount, RANK() OVER (PARTITION BY year ORDER BY amount DESC) AS rank FROM revenue;

    If you omit PARTITION BY, the rank is over all rows, not per year.

    Run the query – rank resets to 1 for each year.

  3. Add the previous year's revenue using LAG

    Extend the query: LAG(amount, 1, 0) OVER (PARTITION BY department ORDER BY year) AS prev_amount

    PARTITION BY department is correct – you want the same department's previous year, not the previous row overall.

    For the earliest year of each department, prev_amount is 0.

  4. Order the final result

    Add ORDER BY year, rank; to present the report logically.

    ORDER BY happens after window functions – it won't affect the ranking.

    Rows are sorted by year ascending, then rank ascending.

  5. Calculate the year-over-year change (optional)

    Add a computed column: amount - LAG(amount, 1, 0) OVER (PARTITION BY department ORDER BY year) AS yoy_change

    You can reuse the LAG expression or use a subquery to avoid repeating it.

    Positive values mean growth, negative mean decline.

Checklist
0/6

Ranking & Comparison Query Checklist

Keep this checklist open while writing your query – tick each item before running.

Exercise2 checks
sql

Write a query that:

  1. 1.Ranks departments by amount within each year (highest amount gets rank 1). Use RANK().
  2. 2.Shows the previous year's amount for that department (use LAG() with default 0).
  3. 3.Orders the result by year ascending, then rank ascending.

Your output should have columns: year, department, amount, rank, prev_amount.

Tables you can query

  • revenue (year, department, amount)
starter code5 lines
SELECT year, department, amount,
  -- TODO: add rank per year
  -- TODO: add previous year's amount
FROM revenue
ORDER BY year, rank;
Check yourself

Test your understanding of ranking and comparison queries.

1Put in orderChecked instantly

Arrange the steps to write a ranking query with year-over-year comparison in the correct order.

  • AAdd LAG() with PARTITION BY department ORDER BY year
  • BWrite SELECT with RANK() OVER (PARTITION BY year ORDER BY amount DESC)
  • CAdd final ORDER BY year, rank
  • DInspect table structure with PRAGMA or SELECT *
2Short answerMarked by AI

Explain the difference between RANK() and ROW_NUMBER() when used in the same window definition. Give an example of when you would choose one over the other.

Graded on Defines the tie-handling behavior of both functions · Provides a concrete scenario for each function · Clarity and accuracy of explanation

Lesson 64

Validate query robustness

Stress-test queries with realistic data volumes, check for unexpected NULLs, duplicate keys, and performance degradation; ensure results match spreadsheet baselines.

15 min3 assessments
Decide

You run the SQL report you built last week. The result has 1,247 rows. Your boss's spreadsheet—built from the same raw data—has 1,246. One row is either extra or missing. You need to find out.

Open your query and start investigating.

What do you check first?

Decision tree for diagnosing row count and value mismatches in SQL queries against a baseline.

  • A row count mismatch between query result and baseline spreadsheet often signals duplicates from joins or missing rows due to NULLs in join columns.
  • Isolate the mismatching segment by comparing counts per natural grouping (date, category) before diving into row-level details.
  • Identify duplicates by grouping on all relevant columns with a HAVING COUNT(*) > 1 clause.
  • Treat NULLs in join keys explicitly: use INNER JOIN for required matches and LEFT JOIN when NULLs should produce a row with NULLs for the other table.
  • Validate aggregated values (sums, averages) against the baseline with the same granularity; a wrong aggregation function or grouping level can produce correct row counts but wrong numbers.
Common misconception

“If the query runs and returns data, it's correct.”

No error message appears, and the output looks plausible—it has rows, columns, and numbers that seem in range. Learners are used to code that either compiles or not, or spreadsheets that calculate automatically without silent mistakes.

What is actually true

A query can silently drop rows (due to NULLs in joins), multiply rows (due to duplicate keys), or compute wrong aggregates (e.g., using SUM where AVG was needed) without any error. The only way to confirm correctness is to compare row counts, spot-check values, and cross-validate aggregates against known baselines.

Checklist
0/7

Query Robustness Validation Checklist

Run through this checklist every time you modify a query or add a new source table.

Exercise1 check
sql

The query above is supposed to list each customer's total order amount. However, the result is missing 'Unknown' for orders with NULL customer_id.

Fix the query to include all orders, even those with NULL customer_id (show the name as 'Unknown' for those). Test that your query returns a row for order 3.

Tables you can query

  • orders (order_id, customer_id, amount)
  • customers (customer_id, name)
starter code4 lines
SELECT c.name, SUM(o.amount) 
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.name;
Check yourself

Test your understanding of query robustness validation.

1Select all that applyChecked instantly

Which of the following are likely causes of a row count mismatch between a SQL query result and a baseline spreadsheet? (Select all that apply.)

  • ANULL values in join columns causing rows to be dropped
  • BDuplicate rows in the source data or produced by a join
  • CUsing a LEFT JOIN instead of INNER JOIN
  • DAn incorrect filter condition that eliminates too many rows
  • EThe query returns an error message
2Short answerMarked by AI

Describe a specific step you would take to check whether duplicate rows are causing a row count mismatch. Include the SQL clause or function you would use.

Graded on Identifies the SQL technique for detecting duplicates · Explains the logic of how this reveals the mismatch