This page explains every exercise in plain language. Each lesson gives you
one question, the idea behind it, a reference query, the result you should expect, and
how to read that result.
You do not need to memorize the query. Read the question first, understand what each SQL
piece does, then try writing it yourself in a blank editor.
120small, progressive exercises
14easy-to-scan sections
120/120 live previewsexpected-result mode
How to practice
For each lesson: read the question, hide the reference query from your attention, write
your own answer, and check it with
sql-lab exercise check Q001 --file answer.sql. Return here when you need the
explanation or want to compare your result.
The store you are querying
The practice database is a small retail store. You only need these six tables for the
full progression from fundamentals through joins, aggregation, set logic, and windows.
customers
People who shop at the store, including optional contact details and signup time.
products
Items the store sells: SKU, name, brand, category, price, and active state.
categories
Small lookup table grouping products into understandable product families.
inventory
Current stock information for products, including deliberate zero and missing cases.
orders
One row per purchase event, with customer, status, sales channel, and timestamps.
order_items
The individual products and quantities inside an order, including historical price.
No lessons match that search. Clear the search box or try a broader term.
Reading data
Start by asking PostgreSQL for rows and choosing which columns should appear.
Q001
Read a whole table
Use SELECT * to inspect every column of a small table.
SELECTstar projection
The question
Return every row and every column from categories.
Understand the idea
A table is a set of rows with named columns. SELECT asks PostgreSQL to return data. The star (*) means “all columns,” and FROM names the table to read. This is the simplest useful query and a good way to inspect a small, unfamiliar table.
Read the query step by step
SELECT starts the request for data.
* means every column in the table.
FROM categories tells PostgreSQL which table supplies the rows.
Reference query
PostgreSQL
SELECT*FROMcategories;
Expected result previewLive PostgreSQL · postgres
id
name
description
created_at
1
Electronics
Consumer electronics and accessories
2024-03-16 23:59:59+00:00
2
Home
Home organization and household goods
2024-03-17 23:59:59+00:00
3
Grocery
Shelf-stable grocery and pantry items
2024-03-18 23:59:59+00:00
4
Office
Office supplies and desk accessories
2024-03-19 23:59:59+00:00
5
Outdoors
Outdoor recreation and travel gear
2024-03-20 23:59:59+00:00
6
Apparel
Everyday clothing and accessories
2024-03-21 23:59:59+00:00
7
Seasonal
Intentional empty category for outer-join practice
2024-03-22 23:59:59+00:00
Showing all 7 returned rows. The exercise checker compares the complete result.
What to expect
You should see one row for each category and every column stored on the categories table. The exact row order is not part of the question.
How to interpret it
Read the column headings first, then scan a few rows. The result is a direct view of the table; the query has not filtered, sorted, or changed any values.
Watch for this
SELECT * is convenient for exploration, but production queries usually name the columns they need so the result shape stays intentional.
Q002
Project specific columns
Return only the columns requested by the question.
SELECTprojection
The question
Return sku, name, and unit_price from products.
Understand the idea
You rarely need every column. A SELECT list lets you choose exactly what the result should contain. This is called projection: it changes the columns you see, not the rows stored in the table.
Read the query step by step
Write the desired column names after SELECT, separated by commas.
FROM products still supplies all product rows.
Only sku, name, and unit_price appear in the output.
Reference query
PostgreSQL
SELECTsku,name,unit_priceFROMproducts;
Expected result previewLive PostgreSQL · postgres
sku
name
unit_price
ANCHOR-OFFICE-MOUSE-A
Classic Wireless Mouse
29.99
ANCHOR-OFFICE-MOUSE-B
Ergonomic Wireless Mouse
29.99
ANCHOR-NEVER-SOLD
Never-Sold Storage Basket
19.99
ANCHOR-UNCATEGORIZED
Uncategorized USB Cable
9.99
ANCHOR-NO-INVENTORY
Discontinued Desk Lamp
39.99
ANCHOR-ZERO-STOCK
Zero-Stock Headphones
79.99
SKU-2-00007
Travel Kettle 007
36.12
SKU-2-00008
Everyday Pillow 008
174.63
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One output row per product with exactly three columns: sku, name, and unit_price.
How to interpret it
The number of product rows is unchanged; only the width of the result is smaller. Naming columns also makes the query easier to understand later.
Q003
Alias output columns
Rename result columns with AS.
AScolumn aliases
The question
Return product name as product_name and unit_price as price from products.
Understand the idea
An alias is a temporary output name. AS does not rename a database column; it only changes the heading shown by this query. Aliases are especially useful when a column name is technical or when a later expression needs a readable label.
Read the query step by step
name AS product_name returns the stored name value under a new heading.
unit_price AS price does the same for the price column.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Two columns named product_name and price, with one row for every product.
How to interpret it
Compare the headings with Q002. The values come from the same stored columns, but the result now has names chosen by the query.
Q004
Remove duplicate values
Use DISTINCT to return unique values.
DISTINCT
The question
Return the distinct brands that appear in products.
Understand the idea
A column can contain the same value on many rows. DISTINCT removes repeated result rows after the selected columns have been chosen. Here it turns a long product list into the set of brands represented in the store.
Read the query step by step
SELECT brand alone would repeat a brand once per matching product.
DISTINCT compares the selected result values and keeps one copy of each unique row.
No GROUP BY is needed when all you want is de-duplication.
Reference query
PostgreSQL
SELECTDISTINCTbrandFROMproducts;
Expected result previewLive PostgreSQL · postgres
brand
Northstar
BrightDesk
SignalWorks
Fieldline
Cedar
Maple & Co
Harbour Home
Showing all 7 returned rows. The exercise checker compares the complete result.
What to expect
Each brand appears once, even when the store has several products from that brand.
How to interpret it
The result answers “which brands exist?” rather than “which product rows exist?” The order is not guaranteed because no ORDER BY is present.
Watch for this
DISTINCT applies to the whole selected row, not independently to each column.
Ordering and limiting results
Control which rows appear first and how many rows you keep.
Q005
Sort ascending
Use ORDER BY in ascending order.
ORDER BYASC
The question
Return sku and name from products ordered by sku from A to Z.
Understand the idea
Tables do not promise a natural display order. ORDER BY explicitly asks PostgreSQL to arrange the result. ASC means ascending: smaller values first, or text from A to Z.
Read the query step by step
ORDER BY is written after FROM and any WHERE clause.
sku is the sort key.
ASC is the ascending direction and is also PostgreSQL's default when no direction is written.
Reference query
PostgreSQL
SELECTsku,nameFROMproductsORDERBYskuASC;
Expected result previewLive PostgreSQL · postgres
sku
name
ANCHOR-NEVER-SOLD
Never-Sold Storage Basket
ANCHOR-NO-INVENTORY
Discontinued Desk Lamp
ANCHOR-OFFICE-MOUSE-A
Classic Wireless Mouse
ANCHOR-OFFICE-MOUSE-B
Ergonomic Wireless Mouse
ANCHOR-UNCATEGORIZED
Uncategorized USB Cable
ANCHOR-ZERO-STOCK
Zero-Stock Headphones
SKU-1-00009
Everyday Speaker 009
SKU-1-00013
Classic Webcam 013
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Products appear from the lowest SKU value to the highest SKU value.
How to interpret it
Look at the sku column from top to bottom. The ordering is now part of the answer, not an accidental property of how PostgreSQL happened to read the table.
Q006
Sort descending
Use ORDER BY in descending order.
ORDER BYDESC
The question
Return sku and name from products ordered by sku from Z to A.
Understand the idea
DESC means descending order. It uses the same ORDER BY mechanism as Q005 but reverses the direction so larger values, later dates, or text nearer Z appears first.
Read the query step by step
ORDER BY sku chooses the SKU as the sort key.
DESC reverses the normal ascending direction.
Only the result order changes; stored rows stay untouched.
Reference query
PostgreSQL
SELECTsku,nameFROMproductsORDERBYskuDESC;
Expected result previewLive PostgreSQL · postgres
sku
name
SKU-6-00087
Classic T-Shirt 087
SKU-6-00085
Studio Gloves 085
SKU-6-00065
Compact Scarf 065
SKU-6-00055
Studio Gloves 055
SKU-6-00050
Essential Gloves 050
SKU-6-00047
Compact Cap 047
SKU-6-00045
Lightweight Cap 045
SKU-6-00044
Travel T-Shirt 044
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
The highest SKU sorts first and the lowest SKU sorts last.
How to interpret it
Compare this result with Q005: it should contain the same rows in the opposite SKU direction.
Q007
Use a tie-breaker sort
Order by more than one column so tied primary values have a stable order.
ORDER BYmultiple sort keys
The question
Return sku, brand, and unit_price from products ordered by brand, then unit_price, then sku, all ascending.
Understand the idea
A sort key can contain ties. Adding more ORDER BY columns tells PostgreSQL how to break those ties. The keys are considered from left to right: later keys matter only when earlier keys are equal.
Read the query step by step
brand is the primary sort key.
unit_price orders products inside the same brand.
sku breaks any remaining price ties, making the displayed order stable and repeatable.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Brands are grouped alphabetically; inside a brand, cheaper products come first; tied prices sort by SKU.
How to interpret it
Pick one brand with several products and verify the second and third rules inside that brand. This is the basic pattern for deterministic multi-key ordering.
Q008
Limit the result
Use LIMIT to return only the first N rows of an ordered result.
LIMIT
The question
Return the five cheapest products as sku, name, and unit_price. Break price ties by sku.
Understand the idea
LIMIT keeps only the first N rows of the result. Because “first” only has meaning when the result is ordered, this query sorts by price before keeping five rows.
Read the query step by step
ORDER BY unit_price puts the cheapest products first.
sku is a tie-breaker when products have the same price.
LIMIT 5 keeps only the first five rows after sorting.
Showing all 5 returned rows. The exercise checker compares the complete result.
What to expect
Exactly five products: the five cheapest, with price ties resolved by SKU.
How to interpret it
This is a common “top N” pattern. The important idea is that LIMIT is applied to an ordered result so the chosen five are predictable.
Watch for this
LIMIT without ORDER BY can return any N rows and should not be read as “lowest,” “latest,” or “best.”
Filtering rows
Keep only rows that satisfy a condition, including missing values and text patterns.
Q009
Filter by equality
Use WHERE with an equality predicate.
WHERE=
The question
Return order_number and status for orders whose status is delivered.
Understand the idea
WHERE removes rows that do not satisfy a condition. Equality uses =. In this question, only orders whose status value is exactly delivered are allowed into the result.
Read the query step by step
FROM orders supplies all orders.
WHERE status = 'delivered' tests each row.
Only rows for which the test is true reach the SELECT output.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every returned row has status delivered; orders in other states are absent.
How to interpret it
Scan the status column: the filter should make every visible value identical.
Q010
Filter with a numeric comparison
Use a greater-than predicate on a numeric column.
WHERE>
The question
Return sku, name, and unit_price for products priced above 100.00.
Understand the idea
Numeric columns can be compared as numbers. The > operator means strictly greater than, so a product priced exactly 100.00 does not satisfy this condition.
Read the query step by step
unit_price > 100.00 is evaluated for each product.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Only products with unit_price greater than 100.00.
How to interpret it
Use the price column to verify the boundary: no returned value should be 100.00 or less.
Watch for this
Do not put numeric values in quotes unless you specifically need text; unit_price is a numeric column.
Q011
Filter a boolean column
Use a boolean predicate directly.
boolean predicate
The question
Return sku and name for inactive products.
Understand the idea
PostgreSQL has true boolean values: TRUE and FALSE. A boolean column can be filtered directly or compared with a boolean literal. Here the query keeps products marked inactive.
Read the query step by step
active is a boolean column.
active = FALSE keeps rows whose stored value is false.
The query returns identifying columns, not the boolean itself.
Reference query
PostgreSQL
SELECTsku,nameFROMproductsWHEREactive=FALSE;
Expected result previewLive PostgreSQL · postgres
sku
name
ANCHOR-NO-INVENTORY
Discontinued Desk Lamp
SKU-6-00012
Classic T-Shirt 012
SKU-6-00044
Travel T-Shirt 044
SKU-6-00050
Essential Gloves 050
SKU-4-00060
Classic Desk Mat 060
SKU-5-00075
Travel Dry Bag 075
SKU-3-00076
Studio Coffee Beans 076
Showing all 7 returned rows. The exercise checker compares the complete result.
What to expect
Only products whose active flag is false.
How to interpret it
These are products the store retains in its history but no longer considers active.
Q012
Combine conditions with AND
Require two predicates to be true at the same time.
AND
The question
Return sku, name, and unit_price for active products priced below 25.00.
Understand the idea
AND combines conditions and requires all of them to be true. This is how you narrow a result to rows that satisfy several rules at the same time.
Read the query step by step
active = TRUE is the first rule.
unit_price < 25.00 is the second rule.
AND keeps a product only when both tests are true.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Only cancelled and refunded orders.
How to interpret it
The result should contain two possible status values and no others.
Q014
Negate a condition
Use NOT to invert a boolean condition.
NOT
The question
Return external_ref, first_name, and last_name for customers who are not active.
Understand the idea
NOT reverses a true/false expression. Because active is already boolean, NOT active means the same idea as active = FALSE, but reads naturally as “not active.”
Read the query step by step
Each customer's active value is treated as a boolean expression.
NOT reverses TRUE to FALSE and FALSE to TRUE for the test.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Products priced from 25.00 through 50.00, inclusive.
How to interpret it
Check boundary rows carefully; values at exactly 25.00 or 50.00 are valid matches.
Watch for this
BETWEEN is inclusive. If one endpoint must be excluded, write explicit >, >=, <, or <= comparisons instead.
Q017
Find NULL values
Use IS NULL rather than equality for missing values.
IS NULLNULL semantics
The question
Return external_ref, first_name, last_name, and email for customers whose email is NULL.
Understand the idea
NULL represents a missing or unknown value. It is not an ordinary value, so comparing a column with = NULL does not work the way equality with text or numbers does. SQL provides IS NULL specifically for this test.
Read the query step by step
email IS NULL asks whether the email value is missing.
Rows with an actual email address are excluded.
The email column remains NULL in every returned row.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Customers whose email value is missing; the email cell is NULL for each row.
How to interpret it
This is the basic pattern for finding incomplete data.
Watch for this
Use IS NULL, not = NULL. NULL follows three-valued SQL logic rather than normal equality rules.
Q018
Find non-NULL values
Use IS NOT NULL to require a present value.
IS NOT NULLNULL semantics
The question
Return external_ref and phone for customers whose phone is not NULL.
Understand the idea
IS NOT NULL is the opposite check: it keeps rows where a value is present. This is useful when an operation requires a usable phone number, email address, date, or other optional field.
Read the query step by step
phone IS NOT NULL tests for a present phone value.
Rows with missing phone values are excluded.
The returned phone column should contain no NULLs.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Customers with a stored phone number.
How to interpret it
A quick scan should show a concrete phone value on every row.
Q019
Match a text pattern
Use LIKE with the percent wildcard.
LIKEwildcards
The question
Return sku and name for products whose sku begins with ANCHOR-.
Understand the idea
LIKE compares text with a pattern. The percent sign (%) is a wildcard meaning “zero or more characters.” Putting it after ANCHOR- means the text must begin with that prefix.
Read the query step by step
'ANCHOR-%' starts with the required literal text ANCHOR-.
% accepts any remaining characters after that prefix.
LIKE is case-sensitive for ordinary PostgreSQL text comparisons.
Reference query
PostgreSQL
SELECTsku,nameFROMproductsWHEREskuLIKE'ANCHOR-%';
Expected result previewLive PostgreSQL · postgres
sku
name
ANCHOR-OFFICE-MOUSE-A
Classic Wireless Mouse
ANCHOR-OFFICE-MOUSE-B
Ergonomic Wireless Mouse
ANCHOR-NEVER-SOLD
Never-Sold Storage Basket
ANCHOR-UNCATEGORIZED
Uncategorized USB Cable
ANCHOR-NO-INVENTORY
Discontinued Desk Lamp
ANCHOR-ZERO-STOCK
Zero-Stock Headphones
Showing all 6 returned rows. The exercise checker compares the complete result.
What to expect
Only products whose SKU begins with ANCHOR-.
How to interpret it
These are deliberately controlled products in the practice dataset, making the prefix easy to verify.
Watch for this
In LIKE patterns, _ is also special: it matches exactly one character.
Q020
Match text case-insensitively
Use PostgreSQL ILIKE for a case-insensitive pattern match.
ILIKE
The question
Return sku and name for products whose name contains the word mouse regardless of case.
Understand the idea
ILIKE is PostgreSQL's case-insensitive pattern match. It works like LIKE, but upper- and lower-case letters are treated as matching for this comparison.
Read the query step by step
%mouse% allows any characters before or after the word mouse.
Showing all 2 returned rows. The exercise checker compares the complete result.
What to expect
Products whose name contains mouse in any mixture of upper- or lower-case letters.
How to interpret it
The condition changes how matching works; it does not convert the displayed name to another case.
Expressions and common functions
Create useful output values without changing the stored data.
Q021
Create a calculated numeric column
Use arithmetic in the SELECT list.
arithmetic expression
The question
Return sku, unit_price, and unit_price multiplied by 1.13 as price_with_tax for every product.
Understand the idea
A SELECT list can contain calculations, not just stored columns. The database computes the expression for each row and returns the result as a new output column without changing the product table.
Read the query step by step
unit_price is read from each product row.
unit_price * 1.13 calculates a 13% increase.
AS price_with_tax gives the calculated output a clear heading.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Each product row shows its stored unit_price and a calculated price_with_tax.
How to interpret it
Use one row to confirm that price_with_tax is exactly unit_price multiplied by 1.13.
Watch for this
A calculated SELECT expression is read-only; it does not update unit_price in the table.
Q022
Concatenate text
Build one display value from multiple text columns.
string concatenation||
The question
Return external_ref and the customer's first and last name joined with one space as full_name.
Understand the idea
The || operator joins text values together. Literal text in quotes can be inserted between columns, which is how the query adds exactly one space between first and last name.
Read the query step by step
first_name supplies the first piece of text.
' ' is a one-space text literal.
last_name supplies the final piece, and AS full_name labels the result.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One full_name value per customer containing first name, one space, then last name.
How to interpret it
The database is formatting a display value from existing columns; the stored names remain separate.
Q023
Transform text case
Use a common scalar string function.
UPPERscalar function
The question
Return sku and brand converted to uppercase as brand_upper from products.
Understand the idea
Functions take input values and return derived values. UPPER returns an upper-case version of text for the result. It does not rewrite the stored brand.
Read the query step by step
brand is passed into UPPER(...).
PostgreSQL returns the transformed text for each row.
AS brand_upper names the transformed column.
Reference query
PostgreSQL
SELECTsku,UPPER(brand)ASbrand_upperFROMproducts;
Expected result previewLive PostgreSQL · postgres
sku
brand_upper
ANCHOR-OFFICE-MOUSE-A
NORTHSTAR
ANCHOR-OFFICE-MOUSE-B
NORTHSTAR
ANCHOR-NEVER-SOLD
HARBOUR HOME
ANCHOR-UNCATEGORIZED
SIGNALWORKS
ANCHOR-NO-INVENTORY
BRIGHTDESK
ANCHOR-ZERO-STOCK
SIGNALWORKS
SKU-2-00007
FIELDLINE
SKU-2-00008
NORTHSTAR
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every brand_upper value is the product brand displayed in upper case.
How to interpret it
This is a simple example of applying the same scalar function independently to every selected row.
Q024
Measure text length
Use CHAR_LENGTH on a text value.
CHAR_LENGTHscalar function
The question
Return sku, name, and the character length of name as name_length from products.
Understand the idea
CHAR_LENGTH counts characters in text. It returns a number, which can be selected next to the original value so you can see both the input and the calculation.
Read the query step by step
name is the source text.
CHAR_LENGTH(name) counts its characters.
AS name_length gives the numeric result a useful heading.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Each product has its name plus an integer showing how many characters that name contains.
How to interpret it
Pick a short name and count its characters manually to connect the function with the output.
Q025
Extract part of a timestamp
Use EXTRACT to obtain one date/time component.
EXTRACTtimestamp
The question
Return order_number and the year from ordered_at as order_year for every order.
Understand the idea
A timestamp contains several parts such as year, month, day, hour, and minute. EXTRACT reads one requested part without changing the original timestamp.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every order shows its order_number and the calendar year taken from ordered_at.
How to interpret it
Orders from different years should display the corresponding year value while keeping the same order identity.
Q026
Perform timestamp arithmetic
Add a PostgreSQL interval to a timestamp.
INTERVALtimestamp arithmetic
The question
Return external_ref, signup_at, and signup_at plus 30 days as thirty_days_after_signup for every customer.
Understand the idea
PostgreSQL intervals represent spans of time. Adding an interval to a timestamp asks the database to calculate another point in time relative to the original value.
Read the query step by step
signup_at is the starting timestamp.
INTERVAL '30 days' represents a thirty-day duration.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
For each customer, the calculated timestamp is exactly 30 days after signup_at.
How to interpret it
Compare the two timestamp columns on the same row; only the date/time calculation changes.
Q027
Replace NULL for display
Use COALESCE to provide a fallback value.
COALESCENULL handling
The question
Return external_ref and a display_email column that replaces NULL email values with the text missing@example.test.
Understand the idea
COALESCE returns the first value in its list that is not NULL. It is commonly used to supply a display fallback while preserving the underlying missing value in storage.
Read the query step by step
PostgreSQL checks email first.
When email is present, that value is returned.
When email is NULL, the fallback missing@example.test is returned instead.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every product receives a readable activity_label of Active or Inactive.
How to interpret it
CASE translates a stored machine-friendly value into a user-friendly label inside the result.
Common table expressions
Give an intermediate result a name so a longer query is easier to read in stages.
Q029
Define and read a CTE
Introduce WITH ... AS (...) and select from the named intermediate result.
CTEWITH
The question
Create a CTE named active_products containing sku and name for active products, then return all rows from that CTE.
Understand the idea
A common table expression, usually called a CTE, is a named intermediate result. WITH defines it at the top of the statement, and the query that follows can read it as if it were a temporary table for that one statement.
Read the query step by step
WITH active_products AS (...) defines the intermediate result.
The inner SELECT keeps only active products and exposes sku and name.
The final SELECT reads rows from active_products by name.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
The final result contains sku and name for active products only.
How to interpret it
The CTE does not add new data; it gives the filtered intermediate result a clear name. This becomes valuable when a larger query has several understandable stages.
Watch for this
A CTE exists only for the statement that defines it; it is not a permanent table or view.
Q030
Use a CTE as a readable transformation step
Put a calculated projection in a CTE and select its named outputs afterward.
CTEreadability
The question
Create a CTE named priced_products with sku, unit_price, and unit_price * 1.13 as price_with_tax; then return sku and price_with_tax from it.
Understand the idea
A CTE can also name a transformation step. Here the first stage calculates a tax-adjusted price, and the final stage chooses which outputs to expose. The point is readability: each stage has a simple job.
Read the query step by step
priced_products computes sku, unit_price, and price_with_tax.
The final SELECT reads from that named intermediate result.
Only sku and price_with_tax are exposed by the final query.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per product with sku and the calculated price_with_tax column.
How to interpret it
Read the statement from top to bottom as two steps: first calculate, then select the final shape. This is the mental model we will reuse in more complex exercises.
Watch for this
This lesson uses a CTE for clarity. It is not claiming that the CTE makes the query faster.
Joining tables
Combine related tables, preserve missing relationships, and see exactly what each join keeps.
Q031
Join orders to customers
Use INNER JOIN to combine rows that have matching keys in two tables.
INNER JOINjoin keys
The question
Return each order_number together with the external_ref of the customer who placed it.
Understand the idea
A relational database deliberately stores different kinds of facts in different tables. An order stores customer_id instead of copying the customer's name and contact details. INNER JOIN follows that relationship so one result row can contain columns from both tables.
Read the query step by step
orders AS o and customers AS c give short aliases to the two tables.
ON c.id = o.customer_id is the matching rule: pair an order with the customer whose id it stores.
INNER JOIN keeps only matched pairs. In this schema every order must reference a real customer, so every order has a match.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per order, with order_number and the matching customer's external_ref.
How to interpret it
The output grain is still one row per order. The join did not summarize anything; it only made customer information available beside each order.
Watch for this
Choose join keys from the relationship, not from columns that merely look similar. Here the designed key is orders.customer_id = customers.id.
Q032
Join order items to products
Use a second INNER JOIN example to attach product details to transactional rows.
INNER JOINlookup join
The question
Return each order_items id, the product sku, and the quantity on that order line.
Understand the idea
Order lines contain product_id because a transaction should point to the product record rather than repeat descriptive product data. Joining order_items to products lets us display the SKU while preserving the order-line row itself.
Read the query step by step
order_items AS oi supplies one row per purchased product line.
products AS p supplies the descriptive SKU.
ON p.id = oi.product_id matches each line to exactly one product.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One output row per order item, containing order_item_id, sku, and quantity.
How to interpret it
Notice that a product can appear on many order-item rows. The join repeats the product's SKU on each matching transaction row because the output grain is one row per order item.
Q033
Chain two joins
Join three tables by adding one relationship at a time.
multiple joinsjoin path
The question
Return order_number, sku, and quantity for every order line.
Understand the idea
Real questions often need facts that are separated by more than one relationship. To connect an order to the products inside it, first join orders to order_items, then join those order items to products.
Read the query step by step
The first JOIN matches o.id to oi.order_id, creating one row per order line.
The second JOIN matches oi.product_id to p.id, adding product information to each line.
Each JOIN has its own ON condition; read the query as a path from orders → order_items → products.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per order line with order_number, sku, and quantity.
How to interpret it
An order containing three products appears three times because the result grain is an order line, not an order. That row multiplication is expected and is exactly why identifying grain matters before joining.
Watch for this
If you expected one row per order, this join would appear to create duplicates. They are not accidental duplicates; they represent different order-item rows.
Q034
Keep every row from the left table
Use LEFT JOIN when unmatched rows from the left side must remain visible.
LEFT JOINunmatched rows
The question
Return every category name and any matching product sku, including categories with no products.
Understand the idea
INNER JOIN removes a left-side row when no match exists. LEFT JOIN changes that rule: every row from the table written on the left is preserved, even when the right table has nothing to contribute.
Read the query step by step
categories is the preserved left table.
products is the optional matching right table.
When a category has no product, PostgreSQL returns the category once and fills p.sku with NULL.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Product categories appear once per matching product, and the intentionally empty Seasonal category appears with a NULL sku.
How to interpret it
The NULL does not mean the category name is missing. It means this preserved category row had no matching product row on the right side.
Watch for this
Putting a condition on a right-table column in WHERE can accidentally remove those NULL-preserved rows and make a LEFT JOIN behave like an INNER JOIN.
Q035
Recognize an optional relationship with LEFT JOIN
Use LEFT JOIN when a related row is optional rather than guaranteed.
LEFT JOINoptional relationship
The question
Return every product sku with its quantity_on_hand, keeping products that have no inventory row.
Understand the idea
LEFT JOIN is also the normal choice for an optional one-to-one relationship. Some products intentionally have no inventory record, and the query should make that absence visible rather than hide the product.
Read the query step by step
products is the preserved side, so every product remains in the output.
inventory is matched with i.product_id = p.id.
A missing inventory row becomes NULL quantity_on_hand; a real zero-stock row remains numeric 0.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per product. Products without an inventory row show NULL; zero-stock products show 0.
How to interpret it
NULL and 0 mean different things here: NULL means no inventory record exists, while 0 means an inventory record exists and explicitly says nothing is currently on hand.
Q036
See unmatched rows from both sides
Use FULL OUTER JOIN to preserve unmatched rows from either input.
FULL OUTER JOINunmatched rows
The question
Return only unmatched categories and products from a FULL OUTER JOIN between categories and products. Show category_name and sku.
Understand the idea
FULL OUTER JOIN preserves unmatched rows from both inputs. That makes it useful when you are reconciling two sets and need to see what is missing on either side, not only what matched.
Read the query step by step
Matching categories and products behave like a normal join.
A category with no products survives with product columns set to NULL.
A product with no category survives with category columns set to NULL.
The final WHERE keeps only those unmatched cases so the behavior is easy to see.
Showing all 2 returned rows. The exercise checker compares the complete result.
What to expect
At least the empty Seasonal category with NULL sku and the ANCHOR-UNCATEGORIZED product with NULL category_name.
How to interpret it
Rows with NULL sku are left-only categories; rows with NULL category_name are right-only products. Those two directions are the defining difference between FULL OUTER JOIN and LEFT JOIN.
Aggregates and GROUP BY
Move from row-level detail to counts, totals, averages, and one-row-per-group summaries.
Q037
Count rows
Use COUNT(*) to reduce many input rows to one count.
COUNTaggregate
The question
Return the total number of orders as order_count.
Understand the idea
An aggregate turns many input rows into a summary value. COUNT(*) asks a simple question: how many rows are in this input set? With no GROUP BY, the entire table is treated as one group.
Read the query step by step
FROM orders supplies every order row.
COUNT(*) counts those rows, including rows whose individual columns may contain NULL.
Because there is no GROUP BY, the result grain is one row for the whole orders table.
Reference query
PostgreSQL
SELECTCOUNT(*)ASorder_countFROMorders;
Expected result previewLive PostgreSQL · postgres
order_count
2400
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
Exactly one row and one column named order_count.
How to interpret it
The number is the table cardinality: the total number of order records currently loaded.
Q038
Sum numeric values
Use SUM to add values across all rows.
SUMaggregate
The question
Return the total quantity across all order_items rows as units_ordered.
Understand the idea
SUM adds numeric values across a set of rows. Here each order-item row has a quantity, so SUM(quantity) answers how many product units were ordered in total, not how many order-item rows exist.
Read the query step by step
Each order_items.quantity value contributes to the aggregate.
SUM returns one numeric result because the query has no GROUP BY.
A line with quantity 4 contributes four units even though it is only one database row.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row with units_ordered equal to the sum of all order-line quantities.
How to interpret it
Compare the meaning with COUNT(*): row count and unit count are different business measures.
Q039
Calculate an average
Use AVG to calculate the arithmetic mean of a numeric column.
AVGaggregate
The question
Return the average product unit_price as average_price.
Understand the idea
AVG calculates the arithmetic mean. For product prices, it adds the non-NULL prices and divides by the number of non-NULL price values. In this schema unit_price is required, so every product participates.
Read the query step by step
unit_price is numeric, so PostgreSQL performs numeric averaging rather than text processing.
AVG ignores NULL inputs by SQL aggregate rules.
With no GROUP BY, there is one average for all products together.
Reference query
PostgreSQL
SELECTAVG(unit_price)ASaverage_priceFROMproducts;
Expected result previewLive PostgreSQL · postgres
average_price
118.1601111111111111
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row with the overall average product price.
How to interpret it
The average is a summary of the product catalog, not the price of any particular product.
Q040
Find the minimum
Use MIN to return the smallest value in a set.
MINaggregate
The question
Return the lowest product unit_price as lowest_price.
Understand the idea
MIN returns the smallest value present in the input set. It is useful for boundaries such as the cheapest price or earliest timestamp.
Read the query step by step
PostgreSQL scans the unit_price values considered by the query.
The smallest non-NULL value becomes lowest_price.
No GROUP BY means one minimum for the entire products table.
Reference query
PostgreSQL
SELECTMIN(unit_price)ASlowest_priceFROMproducts;
Expected result previewLive PostgreSQL · postgres
lowest_price
6.35
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row containing the cheapest unit_price in the catalog.
How to interpret it
This tells you the lower boundary of product prices but does not identify which product owns that price.
Q041
Find the maximum
Use MAX to return the largest value in a set.
MAXaggregate
The question
Return the highest product unit_price as highest_price.
Understand the idea
MAX is the counterpart to MIN: it returns the largest value in the input set.
Read the query step by step
PostgreSQL considers every product unit_price.
The largest non-NULL value becomes highest_price.
The result remains one row because there is no grouping column.
Reference query
PostgreSQL
SELECTMAX(unit_price)AShighest_priceFROMproducts;
Expected result previewLive PostgreSQL · postgres
highest_price
246.77
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row containing the highest unit_price in the catalog.
How to interpret it
This gives the upper price boundary, not the whole product row associated with it.
Q042
Count rows per group
Use GROUP BY to change the output grain from one row overall to one row per status.
GROUP BYaggregation grain
The question
Return each order status and the number of orders in that status as order_count.
Understand the idea
GROUP BY changes the question from one summary for the whole table to one summary per distinct group. Here the output grain is one row per order status.
Read the query step by step
GROUP BY status collects orders sharing the same status value.
COUNT(*) is calculated separately inside each status group.
status may appear in SELECT because it is the grouping column; order_count is allowed because it is aggregated.
Showing all 6 returned rows. The exercise checker compares the complete result.
What to expect
One row per distinct status with the number of orders in that status.
How to interpret it
Adding all order_count values should reproduce the overall order count from Q037.
Watch for this
When a query groups rows, every selected expression normally needs to be either part of the GROUP BY or produced by an aggregate.
Q043
Sum values per group
Combine GROUP BY with SUM so each group gets its own total.
GROUP BYSUM
The question
Return each sales_channel and the sum of total_amount as order_value.
Understand the idea
GROUP BY can calculate any aggregate at the chosen grain, not only counts. This query asks for one row per sales channel and totals the recorded order value inside each channel.
Read the query step by step
sales_channel defines the groups.
SUM(total_amount) runs separately inside each channel group.
The alias order_value gives the aggregate a readable output name.
Showing all 4 returned rows. The exercise checker compares the complete result.
What to expect
One row for each sales channel with that channel's summed order value.
How to interpret it
This is intentionally 'order value,' not accounting revenue: the dataset includes statuses such as cancelled and refunded, and the query does not exclude them. Precise business definitions matter.
Q044
Group by more than one column
Define a more detailed result grain with multiple GROUP BY columns.
GROUP BYmultiple grouping columns
The question
Return status, sales_channel, and the order count for each status/channel combination.
Understand the idea
Grouping can use more than one column. Each unique combination becomes its own group, so adding a second grouping column makes the result more detailed.
Read the query step by step
status is the first grouping dimension.
sales_channel is the second grouping dimension.
COUNT(*) reports how many orders fall into each distinct status/channel pair.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row for each status and sales_channel combination that actually occurs.
How to interpret it
The result grain is not one row per status and not one row per channel; it is one row per pair. State that grain before interpreting the numbers.
Q045
Count distinct values
Use COUNT(DISTINCT ...) when repeated values should count once.
COUNT DISTINCT
The question
Return the number of distinct customers who have placed an order as ordering_customers.
Understand the idea
COUNT(*) counts rows, but sometimes many rows belong to the same entity. COUNT(DISTINCT customer_id) first reduces repeated customer ids to unique values, then counts those unique customers.
Read the query step by step
The orders table can contain many rows for one customer.
DISTINCT applies inside COUNT only to customer_id.
The aggregate returns one number because there is no GROUP BY.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row containing the number of different customers represented in orders.
How to interpret it
This is the number of ordering customers, which is lower than or equal to both total orders and total customers.
Q046
Filter groups with HAVING
Use HAVING to keep or remove groups after aggregation.
HAVINGpost-aggregation filter
The question
Return customer_id and order_count for customers who have placed at least five orders.
Understand the idea
WHERE filters individual input rows before grouping. HAVING exists for a different job: it filters whole groups after aggregates such as COUNT have been calculated.
Read the query step by step
GROUP BY customer_id creates one group per ordering customer.
COUNT(*) calculates that customer's order count.
HAVING COUNT(*) >= 5 keeps only groups whose calculated count reaches the threshold.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Customers with at least five orders, including the deterministic frequent-customer anchor.
How to interpret it
Every returned row has one customer_id and that customer's aggregate order_count.
Watch for this
Do not write WHERE COUNT(*) >= 5; aggregate results are not available at the WHERE stage.
Q047
Aggregate after a join
Join descriptive data first, then aggregate transaction rows at the desired grain.
JOIN + GROUP BYSUM
The question
Return each sold product sku and the total units sold as units_sold.
Understand the idea
A common reporting pattern is join first, then aggregate. The transaction table contains quantities and product_id; the products table contains the readable SKU. Joining gives us both before grouping.
Read the query step by step
INNER JOIN attaches p.sku to every order-item row.
GROUP BY p.sku changes the grain to one row per sold SKU.
SUM(oi.quantity) adds all quantities for that SKU across all matching order lines.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per product that has sold, with total units_sold.
How to interpret it
Never-sold products do not appear because an INNER JOIN starts from order_items, which has no row for them.
Q048
Count related rows without losing empty groups
Combine LEFT JOIN with COUNT(column) so groups with no match report zero.
LEFT JOIN + GROUP BYCOUNT(column)
The question
Return every category name and its product_count, including the empty Seasonal category.
Understand the idea
This exercise combines two earlier ideas for an important edge case: keep empty groups with LEFT JOIN, then count only real matches. COUNT(column) ignores NULL, while COUNT(*) counts the preserved result row itself.
Read the query step by step
LEFT JOIN preserves every category, even when p.id is NULL.
GROUP BY c.id, c.name creates one output row per category.
COUNT(p.id) counts only matched product ids, so an empty category produces 0.
Showing all 7 returned rows. The exercise checker compares the complete result.
What to expect
Every category appears; Seasonal has product_count 0.
How to interpret it
This is the standard pattern for 'show every parent and how many children it has, including zero.'
Watch for this
COUNT(*) would report 1 for an empty preserved category because the LEFT JOIN still emits one NULL-extended result row.
Q049
Calculate conditional aggregates
Use PostgreSQL FILTER to calculate several counts from the same input rows.
FILTERconditional aggregation
The question
Return total_orders, delivered_orders, and cancelled_orders in one row using aggregate FILTER clauses.
Understand the idea
Sometimes one pass over a table should answer several related count questions. PostgreSQL's FILTER clause lets each aggregate count a different subset without changing the rows available to neighboring aggregates.
Read the query step by step
COUNT(*) counts every order.
FILTER (WHERE status = 'delivered') limits only the delivered_orders aggregate.
The cancelled filter independently limits only cancelled_orders.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row with total_orders, delivered_orders, and cancelled_orders.
How to interpret it
Each conditional count is a subset of total_orders, and the three calculations share the same input table scan logically.
Subqueries and set operations
Use one query inside another and combine compatible result sets intentionally.
Q050
Compare with a scalar subquery
Use a subquery that returns one value inside a WHERE condition.
scalar subquery
The question
Return sku, name, and unit_price for products priced above the overall average product price.
Understand the idea
A subquery is a query nested inside another query. A scalar subquery must produce a single value, which can then be used anywhere a single expression is valid. Here that value is the overall average product price.
Read the query step by step
The inner SELECT AVG(unit_price) returns one number.
The outer query examines products row by row.
WHERE unit_price > (...) keeps products whose price is above that one calculated number.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Products whose unit_price is strictly greater than the overall average product price.
How to interpret it
The average is calculated from the same products table but is used as a benchmark for each outer row.
Watch for this
A scalar subquery that unexpectedly returns more than one row causes an error; its shape must match the context where you use it.
Q051
Test whether a related row exists
Use EXISTS when the question only asks whether at least one matching row is present.
EXISTScorrelated subquery
The question
Return external_ref for customers who have at least one order.
Understand the idea
EXISTS is ideal when you only care whether at least one related row exists. It does not need to return or count all matching orders; it answers a yes/no question for each customer.
Read the query step by step
The outer query considers one customer c at a time.
The inner WHERE o.customer_id = c.id refers back to that current outer customer, making the subquery correlated.
If any matching order exists, EXISTS is true and that customer is kept.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Customers with no matching order rows, including CUST-000002.
How to interpret it
This is an anti-match: the output shows absence of a related row rather than presence.
Q053
Combine result sets and remove duplicates
Use UNION to combine compatible result sets with duplicate removal.
UNIONset operation
The question
Return the brands of ANCHOR-OFFICE-MOUSE-A and ANCHOR-NEVER-SOLD using two SELECT statements combined with UNION.
Understand the idea
UNION combines rows from two compatible SELECT results and then removes duplicates from the combined set. The column count and compatible data types must line up by position.
Read the query step by step
The first SELECT returns Northstar from the first mouse anchor.
The second SELECT returns Harbour Home from the never-sold anchor.
UNION stacks both compatible one-column results and removes duplicate rows if any exist.
Showing all 2 returned rows. The exercise checker compares the complete result.
What to expect
Two distinct brand rows: Northstar and Harbour Home. The row order is not guaranteed.
How to interpret it
The inputs were chosen from deterministic anchor products so the exercise demonstrates UNION consistently on every supported dataset profile and seed.
Q054
Combine result sets and preserve duplicates
Use UNION ALL when duplicate rows are meaningful and should not be removed.
UNION ALLset operation
The question
Return the brand of ANCHOR-OFFICE-MOUSE-A followed by the brand of ANCHOR-OFFICE-MOUSE-B using UNION ALL.
Understand the idea
UNION ALL also stacks compatible results, but it deliberately does not remove duplicates. This is often both the semantically correct and cheaper operation when duplicates carry meaning.
Read the query step by step
Each SELECT returns Northstar because both deterministic mouse anchors use that brand.
UNION ALL concatenates the two one-row results without duplicate removal.
The same value therefore survives twice in the combined result.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
Exactly one row: Northstar.
How to interpret it
Because both deterministic inputs contain the same value, the overlap is guaranteed and the meaning of INTERSECT is visible without relying on random background data.
Q056
Subtract one result set from another
Use EXCEPT to keep rows from the first result that do not appear in the second.
EXCEPTset operation
The question
Return the brand of ANCHOR-OFFICE-MOUSE-A except any brand returned by ANCHOR-NEVER-SOLD.
Understand the idea
EXCEPT subtracts the second result set from the first: keep rows from the first that do not occur in the second.
Read the query step by step
The first SELECT returns Northstar from the mouse anchor.
The second SELECT returns Harbour Home from the never-sold anchor.
EXCEPT keeps Northstar because that value is not present in the second result.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
Exactly one row: Northstar.
How to interpret it
Direction matters. Reversing the two SELECT statements would instead return Harbour Home.
Window functions
Calculate ranks, neighboring values, running totals, and per-group selections without losing row detail.
Q057
Number rows in a defined order
Use ROW_NUMBER to assign a unique sequence number without collapsing rows.
ROW_NUMBERwindow function
The question
Return sku, unit_price, and price_row_number ordered from highest price to lowest, breaking ties by sku.
Understand the idea
Window functions calculate information across related rows without collapsing those rows into groups. ROW_NUMBER assigns a unique sequence number according to an order you define.
Read the query step by step
OVER (...) identifies this as a window calculation.
ORDER BY unit_price DESC, sku defines the numbering sequence inside the window.
The outer ORDER BY price_row_number displays rows in that same numbered order.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every product remains visible and receives a unique row number from 1 upward.
How to interpret it
Unlike GROUP BY, no product rows disappear. The new number is additional information attached to each product row.
Q058
Restart row numbering inside each group
Use PARTITION BY so a window function restarts for each category.
PARTITION BYROW_NUMBER
The question
Return category_id, sku, unit_price, and row number within category, ordering each category from most expensive product to least expensive and breaking ties by sku.
Understand the idea
PARTITION BY splits a window into independent groups while still retaining individual rows. The window function restarts inside every partition.
Read the query step by step
PARTITION BY category_id creates a separate numbering space for each category.
ORDER BY unit_price DESC, sku defines the order within each category.
ROW_NUMBER starts at 1 again whenever category_id changes.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Products numbered independently within category, with the most expensive categorized item numbered 1 in each group.
How to interpret it
This is row-level data plus a within-group position; it is not a grouped summary.
Watch for this
PARTITION BY in a window is not the same as GROUP BY. PARTITION BY does not collapse rows.
Q059
Rank rows while keeping ties
Use RANK when equal sort values should receive the same position and leave gaps afterward.
RANKties
The question
Return sku, unit_price, and price_rank using descending unit_price, then order the result by price_rank and sku.
Understand the idea
RANK is useful when equal values should share the same position. If two products tie for a rank, the next rank skips a number because two rows occupied that position.
Read the query step by step
RANK() OVER (ORDER BY unit_price DESC) ranks higher prices first.
Equal unit_price values receive the same price_rank.
The final ORDER BY makes the rank sequence easy to inspect and uses sku only for display tie-breaking.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Equal prices share rank and the following distinct price receives the next consecutive rank.
How to interpret it
Compare Q059 and Q060 around a tied price to see the exact behavioral difference between RANK and DENSE_RANK.
Q061
Look at the previous row with LAG
Use LAG to place a previous period's value beside the current period.
LAGtime comparison
The question
Count orders by month, then return month, order_count, and previous_month_count using LAG ordered by month.
Understand the idea
LAG lets the current row look backward in an ordered window. It is especially useful for period-over-period analysis because it can place the previous period's metric beside the current one.
Read the query step by step
The CTE first produces one row per calendar month with order_count.
LAG(order_count) OVER (ORDER BY month) reads the preceding month's count.
The final ORDER BY displays months chronologically so the relationship is visible.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per active month; the first row has NULL previous_month_count and later rows show the prior month's count.
How to interpret it
Now current and previous counts are on the same row, which is the starting point for calculating month-over-month changes or detecting spikes.
Q062
Look at the next row with LEAD
Use LEAD to place a following row's value beside the current row.
LEADnext row
The question
For customer CUST-000001, return order_number, ordered_at, and next_ordered_at using LEAD ordered by ordered_at.
Understand the idea
LEAD looks forward instead of backward. For a sequence of orders, it can tell each order when the next order occurred without joining the table to itself.
Read the query step by step
The join identifies the deterministic customer by external_ref.
LEAD(o.ordered_at) OVER (ORDER BY o.ordered_at) reads the next timestamp in that customer's filtered sequence.
The final order has no following row, so PostgreSQL returns NULL for next_ordered_at.
Showing all 5 returned rows. The exercise checker compares the complete result.
What to expect
The five anchor orders for CUST-000001 in chronological order, each pointing to the next order except the last.
How to interpret it
Subtracting ordered_at from next_ordered_at later could measure time between purchases, but this lesson focuses only on LEAD itself.
Q063
Calculate a group total without collapsing rows
Use a windowed SUM with PARTITION BY to keep detail rows and repeat the group total beside them.
windowed SUMPARTITION BY
The question
Return order_number, customer_id, total_amount, and customer_order_value, where customer_order_value is the sum of total_amount for that customer.
Understand the idea
A windowed aggregate answers a group-level question while preserving row-level detail. That is the central difference from GROUP BY. Here every order stays visible while each row also shows its customer's total order value.
Read the query step by step
SUM(total_amount) is still an aggregate calculation.
OVER (PARTITION BY customer_id) turns it into a windowed aggregate instead of a grouped aggregate.
Every order for the same customer receives the same customer_order_value total.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One output row per order, plus a repeated customer-level total beside each order.
How to interpret it
Compare the row count with orders: it is unchanged, even though a customer-level aggregate has been calculated.
Q064
Calculate a running total
Use an ordered window frame so each row includes all values up to that point.
running totalwindow frame
The question
Return order_number, ordered_at, total_amount, and running_order_value ordered by ordered_at and id.
Understand the idea
A running total is a windowed SUM whose frame grows from the beginning of an ordered sequence through the current row. Each row therefore reports the cumulative total so far.
Read the query step by step
ORDER BY ordered_at, id creates a deterministic chronological sequence.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW defines the exact frame for each row.
SUM(total_amount) adds only rows inside that expanding frame.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Orders in chronological order with running_order_value increasing by the current row's total_amount each time.
How to interpret it
The first running total equals the first order value; the final running total equals the sum of all order values.
Watch for this
Explicit ROWS framing avoids subtle peer-row behavior that can occur with some default ordered window frames.
Q065
Select the latest row per group
Use ROW_NUMBER in a CTE and keep row number 1 to solve a latest-per-group problem.
latest per groupROW_NUMBER
The question
Return customer_id, order_number, and ordered_at for the latest order of every customer who has orders.
Understand the idea
'Latest row per group' is a common interview and production pattern. Number rows newest-first inside each group, then keep row number 1. A CTE makes the two logical stages easy to read.
Read the query step by step
PARTITION BY customer_id creates one independent order history per customer.
ORDER BY ordered_at DESC, id DESC puts the newest deterministic row first.
The outer WHERE rn = 1 keeps exactly that first row from each populated customer partition.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Exactly the two most expensive categorized products from each populated category, with deterministic price-tie handling.
How to interpret it
LIMIT 2 would keep two rows overall; ranking within partitions is what makes the limit effectively apply separately to every category.
CTEs for readable multi-step queries
Use named stages to aggregate, join, benchmark, and control grain without turning a query into one dense block.
Q067
Aggregate inside a CTE
Build a grouped intermediate result inside WITH before reading it.
CTEGROUP BY
The question
Create a CTE named customer_order_counts with one row per customer_id and its order_count, then return both columns.
Understand the idea
A CTE is not limited to simple filtering. It can also hold a grouped result. In this exercise, the inner query turns many order rows into one row per customer, and the outer query reads that summary by its temporary name.
Read the query step by step
Inside customer_order_counts, GROUP BY customer_id defines one result row per customer who has orders.
COUNT(*) calculates how many order rows belong to each customer group.
The outer SELECT reads customer_id and order_count from the CTE exactly as if it were a temporary table for this statement.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row for every customer who has at least one order, with that customer's order count.
How to interpret it
The important change happens inside the CTE: the grain becomes one row per customer_id. The outer query does not recalculate anything; it simply makes the intermediate result visible.
Watch for this
Customers with no orders do not appear because the CTE starts from the orders table.
Q068
Join a CTE to a table
Treat a named CTE result like a table in a later JOIN.
CTEJOIN
The question
Create customer_order_counts as in Q067, then return external_ref and order_count by joining that CTE to customers.
Understand the idea
After a CTE is defined, later parts of the same SQL statement can join to it just like a table. That lets you do calculation work first and then attach readable descriptive columns afterward.
Read the query step by step
The CTE first produces one row per customer_id with an order_count.
INNER JOIN matches each CTE customer_id to customers.id.
The final SELECT shows external_ref from customers and the already-calculated order_count from the CTE.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per ordering customer, showing a readable external_ref beside that customer's order count.
How to interpret it
The join does not change what the count means. It only replaces an internal numeric customer key with the external identifier that a person can recognize more easily.
Q069
Define more than one CTE
Declare multiple named intermediate results in one WITH clause.
multiple CTEs
The question
Create one CTE with order_count per customer and a second CTE with total_spend per customer, then return customer_id, order_count, and total_spend for customers present in both.
Understand the idea
A WITH clause can define several CTEs. This is useful when a problem contains several independent calculations that you want to name clearly instead of burying inside one large query.
Read the query step by step
order_counts calculates one row per customer with COUNT(*).
customer_spend independently calculates one row per customer with SUM(total_amount).
The final INNER JOIN combines the two customer-level summaries on customer_id.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One customer-level row containing customer_id, order_count, and total_spend.
How to interpret it
Both CTEs have the same grain, so joining them on customer_id is straightforward. The main lesson is structural: separate calculations can be named and combined later.
Watch for this
Multiple CTEs are separated by commas inside one WITH clause; do not write WITH again before the second CTE.
Q070
Chain one CTE into another
Let a later CTE read the result produced by an earlier CTE.
CTE pipeline
The question
Calculate total_spend per customer, rank those totals from highest to lowest in a second CTE, and return the top five customer_id values with total_spend and spend_rank.
Understand the idea
CTEs can depend on earlier CTEs, which creates a readable sequence of transformations. Here one stage calculates customer spending, a second stage ranks those customer totals, and the final stage keeps only the first five.
Read the query step by step
customer_spend groups orders to one row per customer and calculates total_spend.
ranked_spend reads that CTE and assigns ROW_NUMBER in descending spend order, with customer_id as a tie-breaker.
The final SELECT filters spend_rank to 5 or less and orders by that rank.
Showing all 5 returned rows. The exercise checker compares the complete result.
What to expect
Exactly five rows representing the five highest customer spending totals in deterministic rank order.
How to interpret it
Read the query from top to bottom as a small pipeline. Each CTE has one responsibility, so the final filtering rule is easy to understand without re-reading the aggregation and ranking logic.
Watch for this
The customer_id tie-breaker makes the ranking deterministic when two customers have equal total_spend.
Q071
Name a business-rule subset
Put a repeated business rule in a clearly named CTE before summarizing it.
CTEbusiness-rule naming
The question
Create a CTE named fulfilled_orders containing only shipped and delivered orders, then count those orders by sales_channel.
Understand the idea
A CTE can give a business rule a meaningful name. Instead of repeating a status condition later, this query defines what fulfilled_orders means once and then performs the requested summary on that named set.
Read the query step by step
The CTE keeps only orders whose status is shipped or delivered.
The outer query groups those already-filtered rows by sales_channel.
COUNT(*) reports how many fulfilled orders came through each channel.
Showing all 4 returned rows. The exercise checker compares the complete result.
What to expect
One row per sales channel that has at least one shipped or delivered order, with its fulfilled order count.
How to interpret it
The CTE name carries meaning that the raw status list does not. In larger queries, naming important business subsets can make the SQL much easier to review and maintain.
Q072
Pre-aggregate before a join
Change detail rows to the required grain before joining them to another table.
pre-aggregationgrain
The question
Create an order_line_summaries CTE with total units per order_id, then join it to orders and return order_number and units_in_order.
Understand the idea
Joining detail rows too early can accidentally multiply parent rows. One way to control that is to aggregate the detail table to the grain you actually need before joining it to the parent table.
Read the query step by step
order_line_summaries groups order_items by order_id, producing exactly one summary row per order.
SUM(quantity) turns all line-item quantities for an order into units_in_order.
The later join to orders adds order_number while preserving the one-row-per-order grain.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row for every order, showing its order_number and total number of units across all order lines.
How to interpret it
The CTE deliberately changes line-item detail into an order-level summary before the join. That makes the grain of the final result easy to predict and protects later calculations from accidental duplication.
Q073
Use a one-row CTE as a benchmark
Calculate a benchmark once and compare many rows with it in a final query.
CTE benchmarkCROSS JOIN
The question
Calculate total_spend per customer in one CTE, calculate the average of those customer totals in a second CTE, then return external_ref and total_spend for customers above that benchmark.
Understand the idea
Sometimes a query needs a benchmark that is itself calculated from earlier results. A one-row CTE is a clean place to store that benchmark so it can be compared with every row in the final result.
Read the query step by step
customer_spend first calculates one total_spend value for each customer with orders.
benchmark averages those customer-level totals and therefore returns exactly one row.
CROSS JOIN makes that one benchmark value available to every customer row before WHERE keeps totals above the average.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Customers whose total spending is above the average total spending of customers who have placed orders.
How to interpret it
The benchmark is calculated at customer grain, not order grain. That distinction matters: the query compares each customer total with the average of customer totals, not with the average individual order amount.
Watch for this
A CROSS JOIN is safe here because benchmark is intentionally one row. Cross joining a multi-row result would multiply rows.
Use IS DISTINCT FROM when NULL should behave like a comparable value.
IS DISTINCT FROMNULL semantics
The question
Return external_ref and email for every customer whose email is different from alice.frequent@example.test, including customers whose email is NULL.
Understand the idea
SQL normally treats NULL as an unknown value, so comparisons such as email <> 'x' do not become true when email is NULL. IS DISTINCT FROM is useful when you need a definite comparison that treats NULL as a comparable state.
Read the query step by step
The expression compares every email with alice.frequent@example.test.
A different non-NULL email produces TRUE, just as ordinary inequality would.
A NULL email also produces TRUE because NULL is distinct from that non-NULL address.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Every customer except the one whose email exactly equals alice.frequent@example.test, including rows with NULL email.
How to interpret it
This result demonstrates the difference between ordinary three-valued SQL comparison and a NULL-safe comparison. Rows with missing email are intentionally retained instead of disappearing from the WHERE result.
Watch for this
Use IS DISTINCT FROM when NULL should participate in equality/inequality logic rather than mean 'unknown comparison.'
Q075
Choose where NULL values sort
Use NULLS FIRST or NULLS LAST to make missing values appear in a deliberate position.
NULLS LAST
The question
Return sku and category_id ordered by category_id ascending with NULL category_id values last, then by sku.
Understand the idea
When a sort column can be NULL, decide deliberately where those missing values should appear. PostgreSQL lets you say NULLS FIRST or NULLS LAST instead of relying on a default that a reader may not remember.
Read the query step by step
category_id ASC sorts ordinary category ids from smaller to larger.
NULLS LAST moves uncategorized products after all non-NULL category ids.
sku is a second sort key that gives products inside the same category a predictable order.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Categorized products appear first in ascending category_id order; uncategorized products appear last, with SKU tie-breaking.
How to interpret it
The NULL position is now part of the query contract. That makes reports and top/bottom selections more predictable when optional values are present.
Q076
See how COUNT treats NULL
Compare COUNT(*) with COUNT(column) on a nullable column.
COUNT(column)NULL semantics
The question
Return total_customers using COUNT(*) and customers_with_email using COUNT(email) in the same row.
Understand the idea
COUNT has two commonly used forms with different NULL behavior. COUNT(*) counts rows. COUNT(email) counts only rows where email is not NULL. Seeing them together makes that distinction concrete.
Read the query step by step
COUNT(*) counts every customer row.
COUNT(email) ignores rows whose email value is NULL.
Both aggregates read the same table and return their values in one summary row.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row where total_customers is greater than customers_with_email because the dataset contains NULL emails.
How to interpret it
The difference between the two counts equals the number of customers with missing email values. This is a common and useful way to reason about completeness of nullable fields.
Q077
Observe an aggregate over no rows
Recognize that SUM over an empty input returns NULL rather than numeric zero.
empty aggregateSUM
The question
Return SUM(quantity) as units_sold for ANCHOR-NEVER-SOLD by LEFT JOINing products to order_items and filtering to that SKU.
Understand the idea
Most SQL aggregates ignore NULL values. When SUM receives no numeric values at all, its result is NULL rather than zero. The never-sold product gives us a stable case where this behavior is easy to see.
Read the query step by step
LEFT JOIN keeps ANCHOR-NEVER-SOLD even though it has no order_items rows.
The preserved row has NULL for oi.quantity because no matching line item exists.
SUM ignores that NULL and, with no numeric inputs remaining, returns NULL.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row with units_sold shown as NULL.
How to interpret it
NULL here means the aggregate had no numeric input rows, not that the product sold an unknown number of units. The next exercise shows how to translate that technical result into the business value zero.
Q078
Turn an empty aggregate into zero
Wrap an aggregate with COALESCE when the business meaning of no matching rows is zero.
COALESCEempty aggregate
The question
Return ANCHOR-NEVER-SOLD and its units_sold, using COALESCE so the result is 0 instead of NULL.
Understand the idea
If the business meaning of 'no matching sales rows' is zero units sold, apply COALESCE to the aggregate result. The query keeps SQL's normal aggregate behavior internally and supplies the business-friendly fallback afterward.
Read the query step by step
The LEFT JOIN still preserves the never-sold product.
SUM(oi.quantity) returns NULL because there are no sold quantities.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row for ANCHOR-NEVER-SOLD with units_sold equal to 0.
How to interpret it
This is a deliberate semantic choice: for this report, absence of sales means zero sales. In other domains, an empty aggregate might need to remain NULL because 'no observation' and zero can have different meanings.
Q079
Make NOT IN safe when the subquery can contain NULL
Remove NULL values from a NOT IN subquery so SQL's UNKNOWN result cannot discard every candidate row.
NOT INNULL trap
The question
Return category names whose id is not used by any product, using NOT IN and explicitly excluding NULL category_id values inside the subquery.
Understand the idea
NOT IN has a famous NULL edge case. If the values produced by its subquery include NULL, SQL can no longer prove that a candidate is 'not equal to every value,' so rows you expected may disappear.
Read the query step by step
The inner query lists category_id values that products actually use.
WHERE category_id IS NOT NULL removes the uncategorized product's NULL from that list.
The outer NOT IN can then safely find category ids that are absent from the cleaned set.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
The empty Seasonal category should be returned.
How to interpret it
The important lesson is not merely how to find Seasonal. It is why nullable values inside a NOT IN subquery can change the truth value of every comparison and why the NULL possibility must be handled intentionally.
Watch for this
NOT EXISTS is often easier to reason about for anti-matching because it does not have this same NULL-in-the-list trap.
Q080
Use parentheses to make boolean intent explicit
Group OR conditions so AND applies to the intended combined expression.
boolean precedenceparentheses
The question
Return sku, active, and category_id for active products that belong to category 1 or category 4.
Understand the idea
AND and OR have a precedence order just like arithmetic operators do. SQL evaluates AND before OR. Parentheses are the safest way to show the business rule you intend and to avoid a correct-looking but logically different query.
Read the query step by step
active = TRUE is required for every returned row.
The parenthesized expression allows category 1 or category 4.
Because the OR is grouped, inactive rows from either category cannot slip through.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Only active products from category 1 or category 4.
How to interpret it
Read the WHERE clause in words: active AND (category one OR category four). If the parentheses were misplaced or omitted in a more complex condition, the result could represent a different business rule.
Q081
Distinguish a missing row from a zero value
Use LEFT JOIN plus CASE so absent inventory and zero stock are not treated as the same thing.
missing vs zeroCASE
The question
For ANCHOR-NO-INVENTORY and ANCHOR-ZERO-STOCK, return sku and inventory_state as missing, zero, or positive.
Understand the idea
A missing related row and a related row containing zero are different facts. Treating both as zero can hide whether inventory data is absent or whether inventory was recorded and the product is genuinely out of stock.
Read the query step by step
LEFT JOIN keeps both anchor products even when an inventory row is missing.
i.product_id IS NULL identifies a missing inventory record.
Only after that check does quantity_on_hand = 0 identify a real inventory row reporting zero stock.
Showing all 2 returned rows. The exercise checker compares the complete result.
What to expect
ANCHOR-NO-INVENTORY is labeled missing and ANCHOR-ZERO-STOCK is labeled zero.
How to interpret it
The two rows may look similar if you only care whether units are available, but they require different operational responses. SQL should preserve that distinction when the business meaning differs.
Watch for this
COALESCE(quantity_on_hand, 0) would intentionally erase the difference between missing inventory and zero stock.
Q082
Protect a division from zero
Use NULLIF to turn a zero denominator into NULL before division occurs.
NULLIFdivision by zero
The question
For ANCHOR-ZERO-STOCK, return sku, quantity_on_hand, and 100.0 divided by quantity_on_hand as test_ratio without raising a division-by-zero error.
Understand the idea
Division by zero is an error. NULLIF is a compact way to turn a zero denominator into NULL before the division is evaluated, allowing the result to represent an undefined ratio instead of aborting the whole query.
Read the query step by step
The zero-stock anchor guarantees quantity_on_hand equals 0.
NULLIF(quantity_on_hand, 0) returns NULL for that denominator.
100.0 divided by NULL returns NULL, so PostgreSQL can return the row without raising a division-by-zero exception.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row for ANCHOR-ZERO-STOCK with quantity_on_hand 0 and test_ratio NULL.
How to interpret it
A NULL ratio communicates that the calculation is not defined for a zero denominator. That is usually safer than inventing an arbitrary numeric value such as zero.
Q083
Calculate a percentage without integer truncation
Force decimal arithmetic when a ratio should preserve fractional precision.
percentagenumeric division
The question
Return delivered_pct as the percentage of all orders whose status is delivered, using aggregate FILTER and decimal arithmetic.
Understand the idea
Percentages require both a numerator and denominator, and you normally want decimal precision. This query combines conditional counting, decimal arithmetic, and denominator protection into one familiar reporting pattern.
Read the query step by step
COUNT(*) FILTER (...) counts only delivered orders for the numerator.
COUNT(*) supplies the total number of orders for the denominator.
Multiplying by 100.0 keeps decimal precision, while NULLIF protects the denominator if no orders exist.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One decimal percentage representing delivered orders as a share of all orders.
How to interpret it
The result is a rate rather than a raw count, which makes comparisons across datasets of different sizes easier. A value such as 72.5 would mean 72.5 percent of all orders are delivered.
Q084
Use a half-open timestamp range
Filter one calendar month with >= at the start and < at the next boundary.
half-open rangetimestamps
The question
Return order_number and ordered_at for orders in the month immediately before the dataset as-of month, using a half-open time range and lab_dataset_state.
Understand the idea
Timestamp ranges are safest when written as half-open intervals: include the beginning with >= and exclude the next boundary with <. This avoids guessing the final representable instant of a day or month.
Read the query step by step
lab_dataset_state provides the dataset's as_of_date so the range moves with the generated dataset.
The lower bound is the start of the month immediately before the as-of month.
The upper bound is the start of the as-of month and is excluded, so every instant in the previous month is included exactly once.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
All orders from the calendar month immediately before the dataset's as-of month and no orders outside it.
How to interpret it
This pattern composes cleanly across adjacent periods: the upper boundary of one month is the lower boundary of the next, with no overlap and no tiny gap caused by timestamp precision.
Watch for this
Avoid expressions such as <= '23:59:59' for period ends; higher-precision timestamps can fall after that value on the same day.
Q085
Use several COALESCE fallbacks
Provide an ordered fallback chain when more than one nullable value can represent the same need.
COALESCEfallback chain
The question
Return external_ref and preferred_contact using email first, then phone, then the text no contact available.
Understand the idea
COALESCE can take more than two arguments. PostgreSQL checks them from left to right and returns the first value that is not NULL, which is useful for ordered fallback rules such as preferred contact information.
Read the query step by step
email is checked first and is used whenever it is present.
If email is NULL, phone is checked next.
If both contact fields are NULL, the literal text no contact available becomes the final fallback.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per customer with a preferred_contact that uses email when possible, then phone, then the fallback text.
How to interpret it
The order of COALESCE arguments encodes preference. Changing email and phone positions would change the meaning even though the same columns and rows were involved.
Cardinality, relationships, and data quality
Reason about row multiplication, relationship shapes, duplicates, reconciliations, and invariant checks.
Q086
See one-to-many row amplification
Compare the number of parent rows with the number of rows after joining to a repeating child table.
one-to-manyrow amplification
The question
Return order_rows as the number of orders and joined_rows as the row count after INNER JOINing orders to order_items.
Understand the idea
A join can change the number of rows. When one parent order has several child line items, joining orders to order_items repeats the order once for each line. This is called row amplification and is central to SQL correctness.
Read the query step by step
The first scalar subquery counts the original order rows.
The second scalar subquery joins each order to every matching order_items row and counts the resulting rows.
Because multi-line orders exist, joined_rows is larger than order_rows.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row containing two counts, with joined_rows greater than order_rows.
How to interpret it
Neither count is inherently wrong; they answer questions at different grains. Problems begin when a query assumes the joined rows still represent one row per order after a one-to-many join.
Q087
Recover the parent count after a one-to-many join
Use COUNT(DISTINCT parent_key) when joined rows repeat the same parent entity.
COUNT DISTINCTcardinality
The question
After joining orders to order_items, return COUNT(DISTINCT o.id) as distinct_orders.
Understand the idea
After a one-to-many join repeats parent rows, COUNT(DISTINCT parent_id) can count the unique parent entities represented in the joined result. It is useful when the join is necessary but the question still asks about orders, not line items.
Read the query step by step
INNER JOIN creates one joined row per order item.
The same o.id can therefore appear several times for a multi-line order.
COUNT(DISTINCT o.id) counts each repeated order identifier only once.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
A single distinct_orders value equal to the number of orders in the dataset.
How to interpret it
Distinct counting repairs the count for this specific question, but it does not automatically make every other parent measure safe after the join. Monetary totals, for example, may still be duplicated.
Q088
Recognize a many-to-one lookup join
Verify that joining each child row to one parent row preserves the child-table grain.
many-to-onegrain preservation
The question
Return item_rows from order_items and joined_rows after joining order_items to products.
Understand the idea
Not every join amplifies rows. Each order item points to one product, so joining line items to products is a many-to-one lookup: it adds product information while keeping the line-item grain unchanged.
Read the query step by step
item_rows counts the order_items table before the join.
Each order_items.product_id matches one products.id because of the relationship.
The joined count therefore stays equal to the original item count.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row where item_rows and joined_rows are equal.
How to interpret it
The count equality is evidence that the join preserved one-row-per-order-item grain. Understanding which side can repeat is more reliable than memorizing join keywords alone.
Q089
Preserve parent rows across an optional one-to-one relationship
Use LEFT JOIN when some parent rows intentionally have no related row.
optional one-to-oneLEFT JOIN
The question
Return product_rows and joined_rows, where joined_rows counts products after LEFT JOINing inventory.
Understand the idea
An optional one-to-one relationship means a parent can have zero or one related row. LEFT JOIN is the natural way to attach that optional information while guaranteeing that every parent remains visible.
Read the query step by step
product_rows counts all products.
LEFT JOIN adds matching inventory when it exists and preserves products when it does not.
Because inventory has product_id as its primary key, it cannot contribute more than one row for a product.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row where product_rows equals joined_rows even though some products intentionally have no inventory row.
How to interpret it
The relationship is optional but non-repeating. That combination means the left join preserves the product grain and uses NULLs to represent the missing related record.
Q090
Traverse a many-to-many relationship through a bridge table
Use order_items as the bridge between customers and products.
many-to-manybridge table
The question
Return the number of distinct products purchased by customer CUST-000001 as distinct_products_bought.
Understand the idea
Customers and products form a many-to-many business relationship: a customer can buy many products, and a product can be bought by many customers. Relational models represent that relationship through transaction tables rather than a direct link.
Read the query step by step
The customer is matched to the orders they placed.
Each order is then matched to its order_items, which carry product_id.
COUNT(DISTINCT oi.product_id) removes repeated purchases of the same product and counts unique products bought by Alice.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One count showing how many distinct products CUST-000001 has purchased across all of her orders.
How to interpret it
The bridge path matters more than the number itself. orders and order_items connect customers to products while keeping the underlying transaction history available for quantities, dates, and repeated purchases.
Q091
Detect duplicate relationship rows
Group by the columns that should be unique together and keep groups whose count is greater than one.
duplicate detectionHAVING
The question
Find any duplicate order_id, product_id pairs in order_items and return order_id, product_id, and row_count.
Understand the idea
To look for duplicates, first state what combination of columns should identify one logical record. For an order line relationship, order_id plus product_id is a useful candidate rule in this dataset.
Read the query step by step
GROUP BY order_id, product_id creates one group for every distinct pair.
COUNT(*) measures how many physical rows share each pair.
HAVING COUNT(*) > 1 keeps only pairs that occur more than once.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows for the governed dataset, because each product appears at most once within an order.
How to interpret it
An empty result is a successful data-quality outcome here. If rows appeared, the row_count would show exactly which relationship keys were duplicated and how many copies exist.
Q092
Check uniqueness with total versus distinct counts
Compare COUNT(*) with COUNT(DISTINCT key) as a compact uniqueness test.
uniqueness check
The question
Return total_orders and distinct_order_numbers from orders.
Understand the idea
Another quick uniqueness check compares the total row count with the number of distinct key values. When a business key is unique and non-NULL, those counts should be identical.
Read the query step by step
COUNT(*) measures the number of order rows.
COUNT(DISTINCT order_number) measures the number of unique order-number values.
Equality between the two counts confirms that no order_number is repeated in the current data.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One row where total_orders equals distinct_order_numbers.
How to interpret it
This is a compact validation pattern, not a replacement for a database uniqueness constraint. Constraints prevent bad data; validation queries help detect and explain the state of data that already exists.
Q093
Look for orphaned parent references
Use a LEFT JOIN anti-match to test whether foreign-key relationships are complete.
orphan detectionanti-join
The question
Return order_number for any order whose customer_id does not match a customers row.
Understand the idea
An orphan is a child record whose supposed parent does not exist. A LEFT JOIN anti-match is a clear way to search for orphans because it keeps the child first and then looks for a missing parent match.
Read the query step by step
orders is the left side, so every order is preserved initially.
customers is joined on the foreign-key relationship.
WHERE c.id IS NULL keeps only orders for which no customer row matched.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows, because the orders.customer_id foreign key is valid in the governed dataset.
How to interpret it
An empty result proves this particular relationship check found no violations. If a row appeared, its order_number would identify an order that cannot be resolved to a customer.
Q094
Look for orphaned detail references
Apply the same anti-match pattern to a child row that should always point to a valid product.
orphan detection
The question
Return order_item_id for any order_items row whose product_id has no matching products row.
Understand the idea
The same anti-match pattern works for other required relationships. Repeating a small, understandable validation query across important keys is often more useful than one giant query that is hard to diagnose.
Read the query step by step
order_items is preserved on the left side of the join.
products is matched using product_id.
Rows with p.id IS NULL would be line items referring to nonexistent products.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows for the governed dataset.
How to interpret it
If this result is empty, every line item has a resolvable product. Together with Q093, you are beginning to build a small library of relationship-integrity checks.
Q095
Reconcile order subtotals with line items
Aggregate detail rows to order grain and compare the recomputed amount with the stored parent amount.
reconciliationgrain
The question
Return order_number, stored_subtotal, and calculated_subtotal for any order whose subtotal does not equal the sum of its order_items line_total values.
Understand the idea
A stored subtotal should agree with the detail rows from which it was derived. Reconciliation means independently recomputing that value and returning only records where the two versions disagree.
Read the query step by step
The calculated CTE groups order_items to one row per order and sums line_total.
That order-level result is joined to the stored orders row.
WHERE keeps only differences larger than one cent, so matching orders disappear from the result.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows because each governed order subtotal matches the sum of its line totals.
How to interpret it
An empty reconciliation result is meaningful evidence of consistency. If a row appeared, you would immediately see the order number, stored subtotal, and independently calculated subtotal side by side.
Q096
Reconcile a stored total formula
Check whether a stored measure equals the components that are supposed to define it.
data-quality invariant
The question
Return order_number for orders where total_amount differs from subtotal + tax_amount + shipping_amount by more than 0.01.
Understand the idea
Some data-quality rules exist entirely within one row. Here total_amount is supposed to equal subtotal plus tax and shipping. A direct arithmetic comparison can detect any row that breaks that rule.
Read the query step by step
The expression reconstructs the expected total from the three component columns.
The stored total_amount is subtracted from that reconstructed value.
ABS(...) > 0.01 keeps only discrepancies larger than the allowed one-cent tolerance.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows because all governed order totals reconcile with their components.
How to interpret it
This is an invariant: a condition that should always be true for valid data. Writing invariants as SQL queries is a practical way to make business assumptions observable and testable.
Q097
Check event chronology against customer history
Detect business events that occur before the related entity existed.
chronology check
The question
Return order_number and external_ref for any order whose ordered_at is earlier than that customer's signup_at.
Understand the idea
Valid keys do not guarantee valid history. An order that references a real customer can still be impossible if the record says the purchase happened before that customer signed up.
Read the query step by step
Orders are joined to their customers using customer_id.
ordered_at is the purchase event time and signup_at is the customer creation time.
WHERE ordered_at < signup_at returns only chronology violations.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows because every generated order occurs on or after the related customer's signup time.
How to interpret it
This check validates meaning across tables, not just referential integrity. It is a good example of why production data quality requires business rules in addition to database constraints.
Q098
Check event chronology against product history
Verify that a transaction did not reference a product before the product existed.
chronology check
The question
Return order_number and sku for any order line whose order was placed before the product created_at timestamp.
Understand the idea
A product relationship can also be chronologically impossible even when the product id exists. This query verifies that no order line claims to sell a product before that product's recorded creation time.
Read the query step by step
order_items links the order and product involved in each line.
The joins expose o.ordered_at and p.created_at on the same result row.
The WHERE clause keeps only lines whose purchase time predates product creation.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows in the governed dataset.
How to interpret it
An empty result supports the historical consistency of product sales. If violations existed, the order_number and SKU would give direct evidence for investigation.
Q099
Check ingestion chronology
Detect records whose load time is earlier than the business event time.
event timeload time
The question
Return order_number, ordered_at, and loaded_at for any order where loaded_at is earlier than ordered_at.
Understand the idea
Many data systems track both when an event happened and when the record was loaded. Load time should not be earlier than event time in this lab, so a simple comparison can validate that chronology.
Read the query step by step
ordered_at represents the business event time.
loaded_at represents when the record reached the lab's analytical dataset.
WHERE loaded_at < ordered_at asks only for impossible reverse chronology.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows because every governed order is loaded at or after its event time.
How to interpret it
This does not require load time to be close to event time. It only checks direction. Later exercises deliberately study valid but unusually long load delays.
Q100
Check numeric domain rules
Search directly for values that violate an expected non-negative domain.
domain validation
The question
Return product_id, quantity_on_hand, and reorder_level for inventory rows where either numeric value is negative.
Understand the idea
A domain rule describes which values are allowed in a column. Inventory counts and reorder levels cannot be negative, so a direct WHERE condition can expose any row outside that permitted domain.
Read the query step by step
quantity_on_hand < 0 checks current stock for invalid negative values.
reorder_level < 0 checks the configured threshold for the same problem.
OR returns a row if either domain rule is violated.
Showing all 0 returned rows. The exercise checker compares the complete result.
What to expect
No rows because the generated inventory respects both non-negative rules.
How to interpret it
Simple validation queries are valuable because they are easy to explain and diagnose. A returned product_id would point directly to the row that violates the rule.
Time-series SQL
Turn event timestamps into monthly series, compare periods, and build rolling and cumulative measures.
Q101
Bucket timestamps by month
Use date_trunc to map detailed timestamps to a common calendar boundary.
date_trunc
The question
Return order_number, ordered_at, and order_month where order_month is the month boundary produced by date_trunc('month', ordered_at).
Understand the idea
Raw timestamps are often too detailed for reporting. date_trunc maps each timestamp to a calendar boundary such as the start of its month, giving all events in that month the same grouping value.
Read the query step by step
ordered_at keeps the original event timestamp.
date_trunc('month', ordered_at) resets day and time components to the start of that calendar month.
The query shows both values so you can see how many different timestamps map to the same month bucket.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row per order with its original ordered_at and a corresponding first-of-month order_month value.
How to interpret it
No rows are aggregated yet. This exercise only creates the reusable calendar bucket that later questions will group, rank, and compare over time.
Q102
Count orders by month
Group event rows by a truncated calendar period.
monthly aggregation
The question
Return order_month and order_count for every month, ordered from earliest month to latest month.
Understand the idea
Once every event can be mapped to a month, GROUP BY can turn the detailed order table into a monthly time series. Each result row represents one calendar month rather than one order.
Read the query step by step
date_trunc('month', ordered_at) produces the grouping key.
COUNT(*) counts order rows inside each month group.
ORDER BY order_month arranges the 18 monthly rows chronologically.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Eighteen monthly rows, each with an order_count, from the earliest generated month through the as-of month.
How to interpret it
The generator guarantees activity in every one of the 18 months, so gaps in this particular monthly series would be a signal that the dataset contract is not satisfied.
Q103
Sum revenue by month
Build a monthly time series for a numeric measure rather than a row count.
monthly revenue
The question
Return order_month and revenue as SUM(total_amount) for each month, ordered chronologically.
Understand the idea
A time bucket can summarize any measure that makes sense at that period. Replacing COUNT(*) with SUM(total_amount) turns the monthly activity series into a monthly revenue series without changing the grouping idea.
Read the query step by step
The month bucket is still derived from ordered_at.
SUM(total_amount) adds the final order totals inside each month.
Chronological ordering makes changes in monthly revenue easy to scan.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One revenue total for each of the 18 months, ordered from earliest to latest.
How to interpret it
Order volume and revenue are related but different measures. A month can have more orders without necessarily having proportionally more revenue if basket sizes differ.
Q104
Count distinct active customers by month
Use COUNT(DISTINCT ...) inside a time bucket to measure unique participants.
time bucketCOUNT DISTINCT
The question
Return order_month and active_customers as the number of distinct customer_id values ordering in each month.
Understand the idea
Counting orders tells you activity volume, while counting distinct customers tells you how many different people were active. DISTINCT inside the aggregate prevents a customer with several orders in one month from being counted several times.
Read the query step by step
Rows are grouped by order_month.
Within each month, COUNT(DISTINCT customer_id) keeps one copy of each customer identifier.
The result remains one row per month and is ordered chronologically.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Eighteen monthly rows showing how many unique customers placed at least one order in each month.
How to interpret it
Compare active_customers with order_count from Q102. When order_count is larger, some customers placed multiple orders during that month.
Q105
Bring the previous month's value beside the current month
Apply LAG to an already aggregated monthly series.
LAGmonthly series
The question
Return order_month, order_count, and previous_month_count for each month.
Understand the idea
Period-over-period analysis needs the previous period's value beside the current one. LAG is designed for exactly that: it looks backward in an ordered result without joining the table to itself.
Read the query step by step
The CTE first produces one row per month and its order_count.
LAG(order_count) reads the preceding monthly row when ordered by order_month.
The first month has no preceding row, so previous_month_count is NULL there.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
The 18-month series with each month paired with the immediately previous month's order count when one exists.
How to interpret it
LAG does not subtract or calculate change by itself. It simply puts the prior value on the current row so later expressions can compare the two periods cleanly.
Q106
Calculate absolute month-over-month change
Subtract the previous period's value from the current period after using LAG.
period-over-period change
The question
Return order_month, order_count, and order_count_change compared with the previous month.
Understand the idea
Absolute change answers a simple question: how many more or fewer orders occurred than in the previous month? Once LAG has supplied the previous count, the calculation is ordinary subtraction.
Read the query step by step
monthly builds the one-row-per-month count series.
with_previous adds previous_month_count with LAG.
The final expression subtracts previous_month_count from the current order_count.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A chronological monthly series whose first change is NULL and later values are positive, negative, or zero.
How to interpret it
Positive change means activity increased from the previous month; negative change means it decreased. The magnitude is in orders, not percent, so it depends on the absolute size of the months being compared.
Q107
Calculate percentage month-over-month change
Divide the absolute change by the previous period and protect the denominator.
percentage change
The question
Return order_month, order_count, and pct_change_from_previous for each month.
Understand the idea
Percentage change scales a period-over-period difference by the earlier value. That makes a change easier to compare across periods with different starting sizes, but it also requires careful denominator handling.
Read the query step by step
The first two CTE stages create monthly counts and bring in previous_month_count.
Current minus previous gives the numerator of the change.
Dividing by NULLIF(previous_month_count, 0) and multiplying by 100.0 produces a safe decimal percentage.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A monthly percentage-change series, with NULL for the first month because no previous period exists.
How to interpret it
A value of 25 means the current month has 25 percent more orders than the previous month; -25 means 25 percent fewer. The sign gives direction and the magnitude gives relative size.
Q108
Calculate a three-month rolling average
Use an explicit window frame to summarize the current month and two preceding months.
rolling averagewindow frame
The question
Return order_month, order_count, and rolling_3_month_avg for the monthly order-count series.
Understand the idea
A rolling average smooths short-term variation by averaging nearby periods. The explicit ROWS frame makes the definition precise: use the current month and up to two immediately preceding monthly rows.
Read the query step by step
The CTE produces one monthly order_count per row.
ORDER BY order_month establishes the timeline used by the window.
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW limits each average to at most three consecutive monthly rows.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A chronological series where the first row averages one month, the second averages two, and later rows average three months.
How to interpret it
The rolling value changes more gradually than the raw monthly count. That makes it useful as a simple local baseline when you want to see broader direction rather than every month-to-month jump.
Q109
Calculate a cumulative monthly total
Use a running window over monthly summary rows.
cumulative time series
The question
Return order_month, order_count, and cumulative_orders from the first month through each current month.
Understand the idea
A cumulative total answers 'how much has happened up to this point?' rather than 'how much happened in this period?' A running SUM over monthly rows gives that growing total while preserving each month as a separate row.
Read the query step by step
monthly first calculates one order_count per calendar month.
UNBOUNDED PRECEDING starts each window frame at the first monthly row.
CURRENT ROW ends the frame at the month being displayed, so the sum grows over time.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A cumulative_orders value that never decreases and ends at the total number of orders in the dataset.
How to interpret it
The final cumulative value is a useful sanity check: it should equal COUNT(*) from the full orders table because every monthly count has been included exactly once.
Q110
Exclude a partial current period
Use the dataset as-of date to keep only complete months in a monthly series.
partial periodas-of date
The question
Return order_month and order_count for months strictly before the as-of month, ordered chronologically.
Understand the idea
The current reporting period may not be complete. Comparing a partial month with full historical months can create a false drop, so analytical SQL often needs an explicit rule for excluding incomplete periods.
Read the query step by step
lab_dataset_state provides the as_of_date for the generated dataset.
date_trunc('month', as_of_date) is the start boundary of the current as-of month.
WHERE ordered_at < that boundary removes the current month before monthly grouping is calculated.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A chronological monthly count series ending with the month immediately before the dataset's as-of month.
How to interpret it
The query is intentionally conservative: it analyzes only complete calendar months. In a real system, the exact completeness rule should match how and when the source data is considered final.
Anomalies and late-arriving data
Find spikes and drops against explicit baselines and reason about the difference between event time and load time.
Q111
Find the highest-activity month
Order monthly counts from largest to smallest and keep the first row.
spiketop period
The question
Return the month and order_count for the month with the most orders.
Understand the idea
Anomaly work often begins by finding extreme periods. This exercise asks for the month with the largest order count, using the same monthly aggregation you already understand and a descending sort to expose the maximum.
Read the query step by step
GROUP BY produces one order_count per month.
ORDER BY order_count DESC places the busiest month first.
LIMIT 1 keeps only that highest-activity period, with order_month as a deterministic tie-breaker.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
Exactly one row: the deterministic high-activity month created by the dataset generator.
How to interpret it
Finding the extreme does not explain why it happened. It only identifies the period worth investigating. Later exercises compare that month with explicit baselines.
Q112
Find the lowest-activity month
Order monthly counts from smallest to largest and keep the first row.
dropbottom period
The question
Return the month and order_count for the month with the fewest orders.
Understand the idea
The same pattern can find unusually low activity by reversing the sort. The dataset deliberately contains one month below all ordinary months so that this exercise always has a meaningful answer.
Read the query step by step
Monthly order counts are calculated exactly as before.
ORDER BY order_count ASC places the smallest count first.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
Exactly one row: the deterministic low-activity month.
How to interpret it
A low month can represent a real business decline, a source outage, seasonality, or an incomplete period. SQL can identify the symptom; investigation determines the cause.
Q113
Calculate a simple baseline
Use the average of monthly counts as a reference level for the whole series.
baselineAVG
The question
Return average_monthly_orders as the average of the 18 monthly order counts.
Understand the idea
A baseline is a reference value used to decide what 'normal' looks like. The simplest baseline here is the average of the 18 monthly order counts, calculated after the raw orders have already been summarized by month.
Read the query step by step
The monthly CTE creates one order_count per month.
AVG(order_count) then treats those 18 monthly counts as the input values.
The final result is one benchmark value rather than one row per month.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
One average_monthly_orders value representing the mean monthly order count across the full history.
How to interpret it
Averaging monthly counts is different from averaging order rows. The input to AVG must match the level at which you want to define normal behavior.
Q114
Measure each month's deviation from the baseline
Subtract a one-row baseline from every monthly value.
baseline deviation
The question
Return order_month, order_count, and difference_from_average for each month.
Understand the idea
Once a baseline exists, subtracting it from each period tells you how far that period sits above or below the reference. Keeping the signed difference makes direction immediately visible.
Read the query step by step
monthly provides one count per order_month.
baseline averages those monthly counts into one reference row.
CROSS JOIN attaches that one baseline to each month so order_count - average can be calculated.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Eighteen chronological rows with positive, negative, or near-zero difference_from_average values.
How to interpret it
Large positive values point toward high-activity candidates; large negative values point toward low-activity candidates. This is a descriptive measure, not yet a rule for deciding which differences are important.
Q115
Flag an activity spike with a threshold
Compare each monthly count with a multiple of the baseline and keep only unusually high periods.
spike detectionthreshold
The question
Return order_month and order_count for months whose order_count is greater than twice the average monthly order count.
Understand the idea
A detection rule turns a baseline into a yes/no condition. Here a month is called a spike when its order count is more than twice the overall monthly average. The threshold is simple on purpose so the logic is easy to inspect.
Read the query step by step
monthly calculates the period values and baseline calculates their average.
CROSS JOIN makes the average available beside every month.
WHERE order_count > 2 * average_monthly_orders keeps only months that cross the spike threshold.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
At least the deliberately seeded high month; under the governed generator it is the intended clear spike.
How to interpret it
Thresholds should be chosen for the business context. The point of this exercise is the SQL pattern: calculate a baseline, state a rule, and return only the periods that violate the expected range.
Q116
Flag an activity drop with a threshold
Compare each monthly count with a fraction of the baseline and keep unusually low periods.
drop detectionthreshold
The question
Return order_month and order_count for months whose order_count is less than half the average monthly order count.
Understand the idea
A drop rule is the low-side counterpart to a spike rule. This query flags months below half of the average monthly count, which reliably isolates the deliberately seeded low-activity period.
Read the query step by step
The same monthly and baseline CTEs are reused.
0.5 * average_monthly_orders establishes the low threshold.
WHERE keeps months whose order_count is below that threshold.
Showing all 1 returned rows. The exercise checker compares the complete result.
What to expect
At least the deliberately seeded low month; the governed data makes it a clear low-side anomaly.
How to interpret it
High and low rules are often asymmetric in real systems, but the SQL structure remains the same. The important step is making the threshold explicit rather than relying on visual guesswork.
Q117
Compare a month with only its prior history
Build a rolling baseline that excludes the current row from the window.
rolling baseline
The question
Return order_month, order_count, and prior_3_month_avg using the three preceding monthly rows but not the current month.
Understand the idea
Using the current month inside its own baseline can make a spike partly hide itself. A trailing baseline can instead use only earlier periods, giving the current month a reference that it did not help calculate.
Read the query step by step
Monthly counts are ordered chronologically.
ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING selects up to three earlier monthly rows and explicitly excludes the current row.
AVG(order_count) calculates the prior_3_month_avg from only that historical frame.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
A chronological series; the earliest month has no prior baseline, and later rows gain averages from up to three preceding months.
How to interpret it
This rolling historical reference adapts over time and is often more useful than one average across the entire history. It also demonstrates why window-frame boundaries are part of the business definition, not just SQL syntax.
Q118
Find late-arriving orders
Compare event time with load time using an interval threshold.
late-arriving datainterval
The question
Return order_number, ordered_at, and loaded_at for orders loaded at least five days after they were ordered.
Understand the idea
Late-arriving data describes an event that happened at one time but did not reach the analytical system until much later. The orders table stores both timestamps so the delay can be detected directly.
Read the query step by step
ordered_at is the original business event time.
loaded_at is the arrival time in the lab dataset.
The interval condition keeps records whose load time is at least five days after their event time.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One or more rows, including the deterministic late-arrival anchor guaranteed by the generator.
How to interpret it
Late does not mean invalid. These rows are intentionally valid events that arrived slowly. Pipelines must decide how such records affect historical summaries, backfills, and freshness monitoring.
Q119
Measure ingestion delay in hours
Convert a timestamp interval into a numeric duration that can be compared or summarized.
timestamp differenceEXTRACT EPOCH
The question
Return order_number and load_delay_hours for orders loaded at least one day after ordered_at, ordered from longest delay to shortest.
Understand the idea
A timestamp difference is an interval, which is useful but not always convenient for ranking or threshold math. EXTRACT with EPOCH converts that interval to seconds so it can be expressed as a simple numeric number of hours.
Read the query step by step
loaded_at - ordered_at produces the delay interval for each order.
EXTRACT(EPOCH FROM ...) converts that interval to seconds.
Dividing by 3600.0 converts seconds to hours, and descending order places the longest delays first.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
Orders delayed at least one day, ordered from the largest load delay to the smallest.
How to interpret it
A numeric duration is easier to compare, average, and alert on. Keeping order_number beside the duration preserves a path from the metric back to the exact source record.
Q120
Count late arrivals by event month
Group delayed records by the month when the business event actually occurred.
late-arriving dataevent-time grouping
The question
Return order_month and late_order_count for orders loaded at least one day after ordered_at, grouped by ordered_at month and ordered chronologically.
Understand the idea
When late data is summarized, the grouping timestamp must match the question. If you want to know which business periods were affected, group delayed records by event time rather than by the later arrival time.
Read the query step by step
The WHERE clause first keeps orders delayed by at least one day.
date_trunc('month', ordered_at) assigns each late order to the month when the purchase actually happened.
COUNT(*) reports how many delayed records affected each event month, and ORDER BY presents those months chronologically.
Showing the first 8 rows; more rows exist. The exercise checker compares the complete result.
What to expect
One row for each event month containing at least one order that arrived a day or more late.
How to interpret it
This result answers 'which historical business months received late data?' Grouping by loaded_at would answer a different question: 'during which load months did delayed records arrive?' Both can be useful, but they are not interchangeable.