Day 1: Advanced SELECT & filtering
On this page
By the end of this lesson you can
- Identify the tables, rows, columns, primary keys and foreign keys in a database diagram, using Ridgeway Outdoor's tables as examples.
- Write a
SELECTstatement that returns chosen columns and calculated expressions with clear aliases, sorted withORDER BYand a unique tie-breaker. - Remove duplicate rows with
DISTINCTand explain which columns it compares. - Filter rows with comparison operators,
BETWEEN,INandLIKE, and combine conditions withAND,OR,NOTand parentheses so the query returns exactly the intended rows. - Test for missing values with
IS NULLandIS NOT NULL, and explain why= NULLnever matches. - Categorise rows with simple and searched
CASEexpressions. - Replace or neutralise NULLs with
ISNULL,COALESCE,NULLIFandCONCAT_WS, and explain one practical difference betweenISNULLandCOALESCE.
A. Lesson overview
| Week | 1 — Advanced querying foundations |
| Level | Beginner → Intermediate |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Day 0 setup complete: 03_validate_core_data.sql reports ALL 25 CHECKS PASSED |
| Syllabus topic | Advanced SELECT & Filtering — complex WHERE, CASE, NULL handling |
| Foundations added today | Tables, rows, columns and keys; SELECT, aliases, WHERE, ORDER BY, DISTINCT; data types, expressions and logical operators |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Relational basics and your first queries (theory + try-it queries) | 35 min |
| Part 2 — Filtering rows and combining conditions | 25 min |
| Part 3 — NULL, CASE and NULL functions | 30 min |
| Worked examples | 20 min |
| Guided lab | 25 min |
| Independent practice | 25 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. It is the first working day of 2026 at Ridgeway Outdoor. Three colleagues need lists before lunch:
- Merchandising wants a product price list grouped into Budget, Mid-range and Premium bands.
- Marketing is planning a January campaign for active UK customers and needs to know how each one can be contacted — some have no email address, some have no phone number.
- Customer Services wants to see recent orders that still need attention.
None of these requests requires anything more than one table at a time, but each requires precise filtering and careful handling of missing values. That is today’s work.
B. Theory explained clearly
Part 1 — Relational basics and your first queries
B1. Tables, rows, columns and keys
A database is an organised collection of data managed by a database engine such as SQL Server. Inside RetailLab, data is stored in tables.
| Term | Meaning | Ridgeway example |
|---|---|---|
| Table | A set of rows describing one kind of thing | Sales.Customer holds one row per customer |
| Column | A named attribute every row has, with a fixed data type | City, Email, DateJoined |
| Row | One instance of the thing the table describes | Alice Morgan, customer 1 |
| Schema | A named container that groups related tables | Sales, HR, Inventory, Staging |
| Primary key | A column (or columns) whose value uniquely identifies each row. Cannot be NULL. | CustomerID in Sales.Customer |
| Foreign key | A column whose values must match a primary key in another table. It records a relationship. | SalesOrder.CustomerID must match an existing Customer.CustomerID |
So “which customer placed order 1001?” is answered by reading CustomerID on order 1001 and finding the customer row with that same CustomerID. Combining tables like this is called a join. You will meet joins on Day 2 and study them in depth on Day 8; today every query uses one table.
The full table list and every column are in the course guide’s data dictionary. Keep it open in another tab.
Rows have no built-in order. A table is a set of rows. When you query it, SQL Server may return the rows in any order it finds efficient, and that order can change between runs. If order matters, you must ask for it with
ORDER BY.
B2. Your first query: SELECT … FROM
USE RetailLab; -- make RetailLab the current database for this query window
GO
SELECT CustomerID, FirstName, LastName, City
FROM Sales.Customer;
USE RetailLab;switches the query window to the training database. The toolbar’s database drop-down then shows RetailLab. If you forget it and the drop-down says master, you will see Invalid object name ‘Sales.Customer’.GOis not T-SQL. It is an instruction to SSMS meaning “send everything above me to the server as one batch”. You will learn more about batches on Day 4.SELECTlists the columns you want, separated by commas.FROMnames the table, written as schema.table. Always include the schema: it removes ambiguity and avoids extra name-resolution work.- The semicolon ends the statement. T-SQL often tolerates missing semicolons, but some statements require one before them, so the course always uses them.
SELECT * returns every column. It is useful for a first look at a small table, but avoid it in real queries: it returns data you do not need, and the result changes shape if someone adds a column.
Running only part of a script. In SSMS, highlight the lines you want and press F5. Only the highlighted text runs. This is how you will run the individual examples in this lesson from one query window.
Comments. -- starts a comment that runs to the end of the line. /* … */ encloses a comment over several lines. SQL Server ignores comments.
B3. Data types you will meet today
Every column has a data type that fixes what it can hold.
| Type | Holds | Ridgeway example | Literal syntax |
|---|---|---|---|
int |
Whole numbers | CustomerID, Quantity |
42 |
decimal(p, s) |
Exact decimals: p digits in total, s after the point | ListPrice decimal(10,2) |
189.00 |
varchar(n) |
Text, up to n characters, limited character set | ProductCode, Phone |
'TNT-001' |
nvarchar(n) |
Unicode text, any language | FirstName (Zoë, Søren) |
N'Zoë' |
date |
A calendar date, no time | OrderDate |
'2025-12-31' |
bit |
0 or 1 (often used as yes/no) | IsActive |
1 |
Points to remember:
- Text literals use single quotes. Double quotes are not string delimiters in SQL Server.
- Prefix Unicode literals with N:
N'Zoë'. Without it, characters outside the database’s code page can be lost. - To include an apostrophe inside a string, double it:
N'O''Connor'. - Write dates as
'YYYY-MM-DD'. This format is interpreted the same way regardless of the language settings of your login. - To convert explicitly from one type to another, use
CAST(expression AS type), for exampleCAST(ManagerID AS varchar(10)).
B4. Expressions and column aliases
A SELECT list can contain expressions — calculations built from columns, literals, operators and functions.
SELECT ProductCode,
ListPrice,
StandardCost,
ListPrice - StandardCost AS UnitMargin -- an expression with an alias
FROM Inventory.Product;
AS UnitMargin gives the calculated column a name, called an alias. Without it, SSMS shows (No column name). You can alias ordinary columns too: ProductName AS Product. If an alias contains spaces, wrap it in square brackets: AS [Unit Margin]. The course avoids spaces in aliases.
Arithmetic operators are +, -, *, / and % (remainder). Two traps:
SELECT 7 / 2 AS IntegerDivision, -- 3 : int / int discards the remainder
7 / 2.0 AS DecimalDivision, -- 3.500000 : one decimal operand gives a decimal result
7 % 2 AS Remainder; -- 1
The second trap is joining text. The + operator joins strings, but if any part is NULL the whole result is NULL. CONCAT() treats NULL as an empty string, and CONCAT_WS() (concatenate with separator) skips NULLs entirely:
SELECT CustomerID,
Email + N' / ' + Phone AS PlusOperator,
CONCAT(Email, N' / ', Phone) AS ConcatFunction,
CONCAT_WS(N' / ', Email, Phone) AS ConcatWithSeparator
FROM Sales.Customer
WHERE CustomerID IN (3, 5, 14)
ORDER BY CustomerID;
| CustomerID | PlusOperator | ConcatFunction | ConcatWithSeparator |
|---|---|---|---|
| 3 | NULL | chloe.evans@example.net / | chloe.evans@example.net |
| 5 | NULL | / +44 131 496 0105 | +44 131 496 0105 |
| 14 | NULL | / | (empty string) |
B5. The order SQL Server logically processes a query
You write SELECT first, but SQL Server logically evaluates the clauses in this order:
| Step | Clause | What it does |
|---|---|---|
| 1 | FROM |
Chooses the source rows |
| 2 | WHERE |
Keeps only rows whose condition is TRUE |
| 3 | GROUP BY, HAVING |
Groups rows (Day 2) |
| 4 | SELECT |
Computes the output columns and their aliases |
| 5 | DISTINCT |
Removes duplicate output rows |
| 6 | ORDER BY |
Sorts the final result |
This explains a classic error. Because WHERE is evaluated before SELECT, an alias defined in the SELECT list does not exist yet in WHERE:
-- Fails: Invalid column name 'UnitMargin'.
SELECT ListPrice - StandardCost AS UnitMargin
FROM Inventory.Product
WHERE UnitMargin > 100;
-- Works: repeat the expression in WHERE.
SELECT ListPrice - StandardCost AS UnitMargin
FROM Inventory.Product
WHERE ListPrice - StandardCost > 100;
ORDER BY runs after SELECT, so it can use the alias. (On Day 3 you will learn derived tables and on Day 4 CTEs, which let you define a calculation once and filter on it.)
B6. Sorting with ORDER BY
SELECT ProductCode, ProductName, ListPrice
FROM Inventory.Product
ORDER BY ListPrice DESC, -- most expensive first
ProductCode; -- tie-breaker: ASC is the default
ASC(ascending, the default) orDESC(descending) applies to each column separately.- Always add a tie-breaker that is unique, usually the primary key or another unique column. Three products cost £189.00; without
ProductCodeas a second sort column, their relative order is not guaranteed and could differ between runs or machines. - In SQL Server, NULLs sort first in ascending order and last in descending order. Sorting products by
Colourputs the six products without a colour at the top.
B7. Removing duplicates with DISTINCT
DISTINCT removes rows that are identical across every column in the SELECT list.
SELECT DISTINCT Country
FROM Sales.Customer
ORDER BY Country; -- 9 rows: one per country
Compare two versions on the product table, which has 30 rows:
| Query | Rows | Why |
|---|---|---|
SELECT DISTINCT ProductName FROM Inventory.Product; |
27 | Three names appear twice (two jacket colours, two jacket sizes, two boot sizes) |
SELECT DISTINCT ProductName, Colour FROM Inventory.Product; |
28 | The two Storm Shell Waterproof Jacket rows differ in colour (Navy, Red), so both survive. The other pairs have the same colour and collapse to one row |
DISTINCT treats all NULLs as equal to each other for this purpose. It also has a cost: SQL Server must sort or hash the rows to find duplicates. If you find yourself adding DISTINCT to “fix” unexpected duplicates, stop and work out why the duplicates appeared — on Day 8 you will see that this usually signals a join problem.
Part 2 — Filtering rows and combining conditions
B8. WHERE and comparison operators
WHERE keeps only rows for which the condition is TRUE.
| Operator | Meaning | Example |
|---|---|---|
= |
equal | Country = N'Ireland' |
<> (or !=) |
not equal | OrderStatus <> 'Delivered' |
<, <=, >, >= |
less / greater | ListPrice > 200 |
BETWEEN a AND b |
from a to b, inclusive at both ends | ListPrice BETWEEN 100 AND 200 |
IN (list) |
equal to any value in the list | CustomerSegment IN ('VIP', 'Business') |
LIKE pattern |
text matches a pattern | ProductName LIKE N'%Jacket%' |
LIKE wildcards: % matches any number of characters (including none); _ matches exactly one character. ProductCode LIKE 'T_T-%' matches TNT-001 to TNT-004.
Case sensitivity depends on the column’s collation. RetailLab uses a case-insensitive collation (_CI_), so ProductName LIKE N'%jacket%' and LIKE N'%Jacket%' return the same four products.
Dates and BETWEEN. On a date column, OrderDate BETWEEN '2025-12-01' AND '2025-12-31' correctly covers all of December. Columns that also store a time of day behave differently, so the course prefers the half-open pattern OrderDate >= '2025-12-01' AND OrderDate < '2026-01-01', which is correct for both. Day 12 explains why.
B9. AND, OR, NOT — and why parentheses matter
| Operator | Result is TRUE when… |
|---|---|
a AND b |
both are TRUE |
a OR b |
at least one is TRUE |
NOT a |
a is FALSE |
SQL Server evaluates NOT first, then AND, then OR, just as multiplication happens before addition in arithmetic. So
WHERE OrderDate >= '2025-11-01' AND OrderStatus <> 'Delivered' OR ShippingCost > 0
is read as
WHERE (OrderDate >= '2025-11-01' AND OrderStatus <> 'Delivered') OR ShippingCost > 0
which returns every order ever charged for shipping, regardless of date. Worked example 3 shows the difference: 41 rows instead of the intended 20. Whenever a condition mixes AND and OR, add parentheses, even where they are not strictly needed. They document your intent.
Part 3 — NULL, CASE and NULL functions
B10. What NULL means
NULL is a marker for “no value here”. It is not zero, not an empty string and not a value at all. In Ridgeway’s data it carries different business meanings:
| Column | NULL means |
|---|---|
Customer.Email |
Unknown — we do not have it |
SalesOrder.StoreID |
Not applicable — online orders have no store |
SalesOrder.ShippedDate |
Not happened yet (or never will: cancelled) |
Employee.ManagerID |
No manager — top of the hierarchy |
Knowing which meaning applies is a business question, and it affects how you label or replace NULLs in output.
B11. Three-valued logic
Because NULL means “no value”, comparing it with anything — even another NULL — cannot be TRUE or FALSE. The result is a third logical value, UNKNOWN.
| Expression | Result |
|---|---|
NULL = 'Black' |
UNKNOWN |
NULL <> 'Black' |
UNKNOWN |
NULL = NULL |
UNKNOWN |
NOT UNKNOWN |
UNKNOWN |
TRUE AND UNKNOWN |
UNKNOWN |
FALSE AND UNKNOWN |
FALSE |
TRUE OR UNKNOWN |
TRUE |
FALSE OR UNKNOWN |
UNKNOWN |
WHERE keeps only TRUE rows, so UNKNOWN rows are silently dropped. Three consequences:
WHERE Email = NULLreturns no rows, ever. UseWHERE Email IS NULLorWHERE Email IS NOT NULL.<>silently excludes NULLs.WHERE Colour <> N'Black'returns 18 products, not 24: the six products with no colour are neither “black” nor “not black” — they are unknown. If you want them, say so:WHERE Colour <> N'Black' OR Colour IS NULL.NOT INwith a NULL in the list returns nothing.WHERE Colour NOT IN (N'Black', NULL)expands toColour <> N'Black' AND Colour <> NULL; the second part is always UNKNOWN, so no row can be TRUE. This becomes a real risk on Day 3, when the list comes from a subquery.
These rules assume the default
ANSI_NULLS ONsetting, which SSMS uses and which you should never turn off.
B12. CASE expressions
CASE returns a value chosen by conditions. It is an expression, so it can appear anywhere a value can: in SELECT, ORDER BY, WHERE and elsewhere.
Searched CASE — each WHEN has its own condition:
CASE
WHEN ListPrice < 50 THEN 'Budget'
WHEN ListPrice < 150 THEN 'Mid-range'
ELSE 'Premium'
END
Simple CASE — compares one expression with a list of values:
CASE CustomerSegment
WHEN 'VIP' THEN 1
WHEN 'Business' THEN 2
ELSE 3
END
Rules and pitfalls:
WHENclauses are checked top to bottom, and the first TRUE one wins. Put the most specific condition first. If the first test wereListPrice >= 50 THEN 'Mid-range', a £599 tent would be labelled Mid-range and the Premium branch would never be reached.- Without
ELSE, unmatched rows get NULL. Including an explicitELSEmakes the intent clear. - All branches should return compatible data types. Mixing
'Premium'with the number1in one CASE fails when SQL Server tries to convert the text to a number. - A simple CASE uses
=, soCASE Colour WHEN NULL THEN …never matches. Use a searched CASE:WHEN Colour IS NULL THEN ….
B13. Functions for NULLs
| Function | Returns | Notes |
|---|---|---|
ISNULL(a, b) |
a if it is not NULL, otherwise b | T-SQL-specific; exactly two arguments; result has the data type of a |
COALESCE(a, b, c, …) |
The first non-NULL argument | ANSI standard; any number of arguments; result type follows data type precedence across all arguments |
NULLIF(a, b) |
NULL if a = b, otherwise a | Commonly used to avoid division by zero: x / NULLIF(y, 0) |
CONCAT_WS(sep, a, b, …) |
Non-NULL arguments joined with sep | NULL arguments are skipped |
The data-type difference between ISNULL and COALESCE matters. Inventory.Product.Size is nvarchar(10):
SELECT ProductCode,
Size,
ISNULL(Size, N'One size fits all') AS IsNullResult,
COALESCE(Size, N'One size fits all') AS CoalesceResult
FROM Inventory.Product
WHERE ProductCode IN ('TNT-001', 'JKT-001')
ORDER BY ProductCode;
| ProductCode | Size | IsNullResult | CoalesceResult |
|---|---|---|---|
| JKT-001 | M | M | M |
| TNT-001 | NULL | One size f | One size fits all |
ISNULL returns nvarchar(10), so the replacement text is silently truncated to ten characters. COALESCE picks a type wide enough for its longest argument. Prefer COALESCE unless you have a specific reason to use ISNULL.
NULLIF in action — division by zero raises error 8134, but dividing by NULL simply returns NULL:
SELECT 10 / NULLIF(0, 0) AS SafeDivision; -- NULL, no error
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Inventory.Product, Sales.Customer, Sales.SalesOrder, HR.Employee |
| Relationships used | None directly; each query reads one table |
| Permissions | SELECT on the tables above. Your setup account already has this |
| Objects created today | None. Day 1 is read-only |
Relevant columns
| Table | Columns used today |
|---|---|
Inventory.Product |
ProductCode, ProductName, ListPrice, StandardCost, Colour, Size, Tags |
Sales.Customer |
CustomerID, FirstName, LastName, Email, Phone, City, Country, CustomerSegment, IsActive |
Sales.SalesOrder |
OrderID, OrderDate, SalesChannel, OrderStatus, ShippingCost |
HR.Employee |
EmployeeID, FirstName, LastName, JobTitle, ManagerID, HireDate, TerminationDate, Salary |
Preparation. If you have not changed anything since setup, no preparation is needed. If you are unsure, run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive: drops and rebuilds RetailLab), or run scripts 00 → 03.
Validation. Run this before you start. It should return 30, 40, 376 and 16.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Inventory.Product) AS Products,
(SELECT COUNT(*) FROM Sales.Customer) AS Customers,
(SELECT COUNT(*) FROM Sales.SalesOrder) AS Orders,
(SELECT COUNT(*) FROM HR.Employee) AS Employees;
(COUNT(*) counts rows. You will study it properly tomorrow.)
D. Worked examples
Open a new query window, run USE RetailLab; once, then highlight and run each example.
Worked example 1 — Product margins, sorted with a tie-breaker
/* Which products earn the most per unit sold?
UnitMargin = price minus cost
MarginPct = margin as a percentage of price, rounded to 2 decimal places */
SELECT ProductCode,
ProductName,
ListPrice,
StandardCost,
ListPrice - StandardCost AS UnitMargin,
CAST((ListPrice - StandardCost) * 100 / NULLIF(ListPrice, 0)
AS decimal(5,2)) AS MarginPct
FROM Inventory.Product
ORDER BY UnitMargin DESC, -- ORDER BY may use the alias
ProductCode; -- unique tie-breaker
What to notice
NULLIF(ListPrice, 0)guards against a zero price. No product is free today, but the query will not fail if one is added.CAST(… AS decimal(5,2))rounds the percentage to two decimal places for display.
Expected result: 30 rows. The first six:
| ProductCode | ProductName | ListPrice | StandardCost | UnitMargin | MarginPct |
|---|---|---|---|---|---|
| TNT-004 | Classic Canvas Bell Tent | 599.00 | 320.00 | 279.00 | 46.58 |
| TNT-002 | Summit 4-Person Tent | 349.00 | 180.00 | 169.00 | 48.42 |
| SLP-002 | Arctic Down Sleeping Bag | 299.00 | 150.00 | 149.00 | 49.83 |
| NAV-004 | Satellite Messenger | 349.00 | 210.00 | 139.00 | 39.83 |
| TNT-003 | Ultralight Solo Shelter | 249.00 | 120.00 | 129.00 | 51.81 |
| NAV-002 | Handheld GPS Unit | 299.00 | 190.00 | 109.00 | 36.45 |
Business meaning: the bell tent has the highest margin per unit, but it was discontinued at the end of 2024 — a reminder that a single number rarely tells the whole story.
Worked example 2 — Where are our customers?
SELECT DISTINCT Country
FROM Sales.Customer
ORDER BY Country;
Expected result: 9 rows — Denmark, France, Germany, Ireland, Italy, Spain, Sweden, United Kingdom, United States. DISTINCT collapses 40 customer rows into one row per country. ORDER BY is needed: without it, the list could come back in any order.
Worked example 3 — Recent orders needing attention (parentheses matter)
Customer Services asks: “Since 1 November 2025, which orders were either not yet delivered, or charged for shipping?”
-- Correct: the OR is grouped, then combined with the date condition.
SELECT OrderID, OrderDate, SalesChannel, OrderStatus, ShippingCost
FROM Sales.SalesOrder
WHERE OrderDate >= '2025-11-01'
AND (OrderStatus <> 'Delivered' OR ShippingCost > 0)
ORDER BY OrderDate, OrderID;
Expected result: 20 rows, from order 1333 (1 Nov, Delivered, £4.95 shipping) to order 1375 (30 Dec, Pending). They include the one cancelled order in the period (1344), eight Shipped and four Pending orders.
Now remove the parentheses:
-- Wrong: AND binds first, so the date applies only to the status test.
SELECT OrderID, OrderDate, SalesChannel, OrderStatus, ShippingCost
FROM Sales.SalesOrder
WHERE OrderDate >= '2025-11-01'
AND OrderStatus <> 'Delivered' OR ShippingCost > 0
ORDER BY OrderDate, OrderID;
Expected result: 41 rows. The extra 21 are older orders that were charged shipping, going back to January 2024. Nothing errors — the query is simply answering a different question. That is why precedence mistakes are dangerous.
Worked example 4 — Missing contact details (IS NULL, not = NULL)
-- Returns 0 rows: a comparison with NULL is UNKNOWN, never TRUE.
SELECT CustomerID, FirstName, LastName, Email, Phone
FROM Sales.Customer
WHERE Email = NULL;
-- Correct.
SELECT CustomerID, FirstName, LastName, Email, Phone
FROM Sales.Customer
WHERE Email IS NULL
OR Phone IS NULL
ORDER BY CustomerID;
Expected result (second query): 12 rows — customers 3, 5, 7, 10, 14, 17, 21, 25, 29, 32, 37 and 40. Customers 14 (Niamh Kelly) and 37 (Leo Fitzgerald) have neither an email nor a phone number.
Worked example 5 — Price bands with CASE, sorted by band
SELECT ProductCode,
ProductName,
ListPrice,
CASE
WHEN ListPrice < 50 THEN 'Budget'
WHEN ListPrice < 150 THEN 'Mid-range'
ELSE 'Premium'
END AS PriceBand
FROM Inventory.Product
ORDER BY CASE -- sort Premium first, then Mid-range, then Budget
WHEN ListPrice < 50 THEN 3
WHEN ListPrice < 150 THEN 2
ELSE 1
END,
ListPrice DESC,
ProductCode;
Why CASE appears twice. Sorting by the alias PriceBand would sort alphabetically (Budget, Mid-range, Premium), which is not the business order. A second CASE in ORDER BY supplies a custom sort key.
Expected result: 30 rows: 12 Premium (TNT-004 at £599.00 first), 10 Mid-range and 8 Budget (COK-003 Gas Canister at £6.50 last). Because < is used, a product priced exactly £150.00 would be Premium and one at exactly £50.00 Mid-range; boundary rules like this should be agreed with the business.
Worked example 6 — One contact detail per customer with COALESCE
SELECT CustomerID,
CONCAT_WS(N' ', FirstName, LastName) AS CustomerName,
COALESCE(Email, Phone, N'No contact details') AS ContactDetail
FROM Sales.Customer
WHERE CustomerID IN (1, 5, 14)
ORDER BY CustomerID;
Expected result
| CustomerID | CustomerName | ContactDetail |
|---|---|---|
| 1 | Alice Morgan | alice.morgan@example.com |
| 5 | Emma Wilson | +44 131 496 0105 |
| 14 | Niamh Kelly | No contact details |
COALESCE checks its arguments left to right and returns the first that is not NULL: email if known, otherwise phone, otherwise the fallback text.
E. Guided practical lab — Marketing’s January contact list
Marketing wants every active UK customer, how to contact them, and a list sorted VIPs first. Build the query one step at a time in a single query window. Run each step before moving on.
Step 1 — Look at the raw data
- Do: Inspect the whole customer table.
- Run:
USE RetailLab; GO SELECT * FROM Sales.Customer ORDER BY CustomerID; - Observe: 40 rows (the count is shown in the bottom-right of SSMS). Scroll right: some
EmailandPhonecells say NULL; three customers haveIsActive = 0. - Why it matters: Looking at the data before filtering it tells you which problems to expect.
- Confirm: The status bar shows 40 rows.
Step 2 — Keep only active UK customers
- Do: Replace
*with the columns Marketing needs and add aWHEREclause with two conditions. - Run:
SELECT CustomerID, FirstName, LastName, CustomerSegment, Email, Phone FROM Sales.Customer WHERE Country = N'United Kingdom' AND IsActive = 1 ORDER BY CustomerID; - Observe: Both conditions must be TRUE for a row to appear.
- Why it matters: Inactive customers should not receive marketing; non-UK customers are out of scope for this campaign.
- Confirm: 23 rows.
Step 3 — Prove to yourself that = NULL does not work
- Do: Find the active UK customers with no email, first the wrong way, then the right way.
- Run:
-- Wrong SELECT CustomerID, FirstName, LastName FROM Sales.Customer WHERE Country = N'United Kingdom' AND IsActive = 1 AND Email = NULL; -- Right SELECT CustomerID, FirstName, LastName FROM Sales.Customer WHERE Country = N'United Kingdom' AND IsActive = 1 AND Email IS NULL ORDER BY CustomerID; - Observe: The first query returns 0 rows without any error. The second returns 2 rows: Emma Wilson (5) and Henry Shaw (29).
- Why it matters: A query that silently returns nothing is harder to spot than one that fails.
- Confirm: Second query = 2 rows.
Step 4 — Decide the contact channel with CASE
- Do: Add a column that says which channel Marketing should use.
- Run:
SELECT CustomerID, FirstName, LastName, CustomerSegment, CASE WHEN Email IS NOT NULL THEN 'Email' WHEN Phone IS NOT NULL THEN 'Phone' ELSE 'Post only' END AS ContactChannel FROM Sales.Customer WHERE Country = N'United Kingdom' AND IsActive = 1 ORDER BY CustomerID; - Observe: Customers 5 and 29 show Phone; everyone else shows Email. No active UK customer is Post only (the two customers with no details at all are in Ireland, and one of them is inactive).
- Why it matters: The order of
WHENclauses encodes a business rule — email is preferred over phone. - Confirm: 23 rows, exactly 2 with Phone.
Step 5 — Add the contact detail itself with COALESCE
- Do: Add a column holding the email, or the phone if there is no email.
- Run: add this line after the CASE expression (remember the comma before it):
, COALESCE(Email, Phone, N'No contact details') AS ContactDetail - Observe: For customers 5 and 29 the column shows a phone number.
- Why it matters: One column that is always populated is easier for a mail-merge tool than two partly empty ones.
- Confirm: No row shows NULL in
ContactDetail.
Step 6 — Sort VIPs first, then by surname
- Do: Replace
ORDER BY CustomerIDwith a business-friendly sort. - Run: the complete query:
SELECT CustomerID, FirstName, LastName, CustomerSegment, CASE WHEN Email IS NOT NULL THEN 'Email' WHEN Phone IS NOT NULL THEN 'Phone' ELSE 'Post only' END AS ContactChannel, COALESCE(Email, Phone, N'No contact details') AS ContactDetail FROM Sales.Customer WHERE Country = N'United Kingdom' AND IsActive = 1 ORDER BY CASE CustomerSegment WHEN 'VIP' THEN 1 WHEN 'Business' THEN 2 ELSE 3 END, LastName, CustomerID; -- tie-breaker in case two customers share a surname - Observe: Four VIPs (Ali, Bennett, Brooks, Morgan), then five Business customers (Mehta, Rahman, Shaw, Thompson, Wright), then fourteen Consumers starting with Baker and ending with Wilson.
- Why it matters: A simple CASE in
ORDER BYgives you a custom sort order without changing any data. - Confirm: 23 rows; first row Farah Ali (6), last row Emma Wilson (5).
You have just combined every idea from today in one realistic query.
F. Independent practice
Write each query yourself before looking at the solutions in section G. Every query must use ORDER BY with a unique tie-breaker.
Task 1 — Mid-priced products (straightforward)
Merchandising wants all products priced from £100 to £200 inclusive.
- Return
ProductCode,ProductNameandListPrice. - Sort by price, highest first, then by product code.
Success criteria: 10 rows. The first is BPK-002 (£199.00); the last is FTW-003 (£109.00). Three products at £189.00 appear in code order.
Hint
BETWEEN includes both end values.
Task 2 — Everything that is not black (NULL awareness)
A photographer needs every product that is not black, including products for which colour does not apply.
- Return
ProductCode,ProductNameandColour. - Sort by product code.
Success criteria: 24 rows, including the six products whose Colour is NULL (COK-001, COK-002, COK-003, LGT-002, NAV-001, NAV-003).
Hint
Run WHERE Colour <> N'Black' first and count the rows. Which products went missing, and why?
Task 3 — 2025 orders that need action (precedence and CASE)
Customer Services wants all orders placed in 2025 that are either cancelled, or are online orders still pending.
- Return
OrderID,OrderDate,SalesChannel,OrderStatus, and a columnActionNeededshowing Review cancellation for cancelled orders and Chase dispatch for pending ones. - Use the half-open date pattern (
>=start,<next start). - Sort by order date, then order ID.
Success criteria: 8 rows — four cancellations (orders 1255, 1273, 1301, 1344) and four pending orders (1372–1375).
Hint
Write the business rule in words with brackets first: placed in 2025 AND ( cancelled OR ( online AND pending ) ).
Task 4 — Employee directory (challenging)
HR wants a directory of all 16 employees with these columns:
| Column | Rule |
|---|---|
EmployeeID |
as stored |
FullName |
first name, a space, last name |
JobTitle |
as stored |
EmploymentStatus |
Left if the employee has a termination date; otherwise New starter if hired on or after 1 January 2025; otherwise Established |
ReportsTo |
the manager’s ID as text, or None (top of hierarchy) when there is no manager |
SalaryBand |
Band A for £70,000 or more, Band B for £35,000 to £69,999.99, Band C below £35,000 |
Sort current employees before leavers, then by last name, then by employee ID.
Success criteria: 16 rows. First row Tom Baxter (4); last row James Reid (16, Left). Oliver Grant is the only New starter. Eleanor Hughes shows None (top of hierarchy). Five employees are in Band A.
Hints
ManagerIDis anint; your fallback text is not. Convert the ID withCAST(ManagerID AS varchar(10))before you useCOALESCE.- “Current employees first” is a custom sort: use a CASE in
ORDER BYthat returns 0 whenTerminationDate IS NULLand 1 otherwise.
Stretch challenge — Catalogue labels for waterproof products (optional)
The website needs a display label for every product whose Tags contain the word waterproof. The label is the product name, then — only if there is colour or size information — a hyphen and the details, for example:
Storm Shell Waterproof Jacket - Navy, size MWaterproof Map Case(no colour or size, so no hyphen and no stray commas)
Return ProductCode and CatalogueLabel, sorted by product code.
Success criteria: 5 rows: FTW-001, FTW-002, JKT-001, JKT-002 and NAV-003, with no trailing separators or the text “size “ on its own.
Hint
CONCAT_WS skips NULL arguments. N'size ' + Size is NULL when Size is NULL — the + operator’s NULL behaviour, used deliberately. What does CONCAT_WS return when every argument after the separator is NULL, and how can NULLIF turn that into NULL?
G. Solutions and explanations
Show solution 1
SELECT ProductCode, ProductName, ListPrice
FROM Inventory.Product
WHERE ListPrice BETWEEN 100 AND 200
ORDER BY ListPrice DESC, ProductCode;
| ProductCode | ProductName | ListPrice |
|---|---|---|
| BPK-002 | Expedition Pack 65L | 199.00 |
| JKT-001 | Storm Shell Waterproof Jacket | 189.00 |
| JKT-002 | Storm Shell Waterproof Jacket | 189.00 |
| TNT-001 | Trailhead 2-Person Tent | 189.00 |
| JKT-003 | Glen Insulated Jacket | 159.00 |
| JKT-004 | Glen Insulated Jacket | 159.00 |
| FTW-001 | Ridge Walking Boot | 139.00 |
| FTW-002 | Ridge Walking Boot | 139.00 |
| SLP-001 | Dreamer 3-Season Sleeping Bag | 129.00 |
| FTW-003 | Trail Runner Shoe | 109.00 |
Reasoning. BETWEEN 100 AND 200 is shorthand for ListPrice >= 100 AND ListPrice <= 200; both forms are acceptable. ProductCode is unique, so it makes the order fully deterministic among the tied prices.
Show solution 2
SELECT ProductCode, ProductName, Colour
FROM Inventory.Product
WHERE Colour <> N'Black'
OR Colour IS NULL
ORDER BY ProductCode;
Result: 24 rows. Colour <> N'Black' alone returns 18, because for the six NULL colours the comparison is UNKNOWN and WHERE drops them.
Acceptable alternative: WHERE COALESCE(Colour, N'') <> N'Black' returns the same 24 rows. It reads less clearly, and wrapping a column in a function can stop SQL Server using an index on that column (Day 24), so the explicit OR … IS NULL form is preferred.
Show solution 3
SELECT OrderID,
OrderDate,
SalesChannel,
OrderStatus,
CASE
WHEN OrderStatus = 'Cancelled' THEN 'Review cancellation'
ELSE 'Chase dispatch'
END AS ActionNeeded
FROM Sales.SalesOrder
WHERE OrderDate >= '2025-01-01'
AND OrderDate < '2026-01-01'
AND ( OrderStatus = 'Cancelled'
OR (SalesChannel = 'Online' AND OrderStatus = 'Pending'))
ORDER BY OrderDate, OrderID;
| OrderID | OrderDate | SalesChannel | OrderStatus | ActionNeeded |
|---|---|---|---|---|
| 1255 | 2025-06-04 | Online | Cancelled | Review cancellation |
| 1273 | 2025-07-02 | Online | Cancelled | Review cancellation |
| 1301 | 2025-08-24 | Online | Cancelled | Review cancellation |
| 1344 | 2025-11-20 | Online | Cancelled | Review cancellation |
| 1372 | 2025-12-28 | Online | Pending | Chase dispatch |
| 1373 | 2025-12-29 | Online | Pending | Chase dispatch |
| 1374 | 2025-12-30 | Online | Pending | Chase dispatch |
| 1375 | 2025-12-30 | Online | Pending | Chase dispatch |
Reasoning. The outer parentheses make the date range apply to both alternatives. The inner parentheses group the two pending-order conditions; because AND binds before OR they are not strictly required, but they make the rule readable. The ELSE branch is safe here because the WHERE clause guarantees every row is either cancelled or pending; if you prefer to be explicit, use WHEN OrderStatus = 'Pending' THEN 'Chase dispatch' and no ELSE.
Common mistake: writing the conditions in a different order without brackets — … AND SalesChannel = 'Online' AND OrderStatus = 'Pending' OR OrderStatus = 'Cancelled' — detaches the date range from the cancellation test. The query then returns 11 rows, including the 2024 cancellations 1013, 1032 and 1118.
Show solution 4
SELECT EmployeeID,
CONCAT_WS(N' ', FirstName, LastName) AS FullName,
JobTitle,
CASE
WHEN TerminationDate IS NOT NULL THEN 'Left'
WHEN HireDate >= '2025-01-01' THEN 'New starter'
ELSE 'Established'
END AS EmploymentStatus,
COALESCE(CAST(ManagerID AS varchar(10)), 'None (top of hierarchy)') AS ReportsTo,
CASE
WHEN Salary >= 70000 THEN 'Band A'
WHEN Salary >= 35000 THEN 'Band B'
ELSE 'Band C'
END AS SalaryBand
FROM HR.Employee
ORDER BY CASE WHEN TerminationDate IS NULL THEN 0 ELSE 1 END,
LastName,
EmployeeID;
| EmployeeID | FullName | JobTitle | EmploymentStatus | ReportsTo | SalaryBand |
|---|---|---|---|---|---|
| 4 | Tom Baxter | Finance Manager | Established | 1 | Band A |
| 3 | Fiona Campbell | Head of Operations | Established | 1 | Band A |
| 15 | Oliver Grant | Sales Associate | New starter | 6 | Band C |
| 1 | Eleanor Hughes | Managing Director | Established | None (top of hierarchy) | Band A |
| 5 | Aisha Khan | IT Manager | Established | 1 | Band A |
| 9 | Megan Lewis | Sales Associate | Established | 6 | Band C |
| 13 | Marek Nowak | Warehouse Operative | Established | 11 | Band C |
| 6 | Liam O’Connor | Sales Team Lead | Established | 2 | Band B |
| 8 | Daniel Okafor | Sales Associate | Established | 6 | Band C |
| 2 | Raj Patel | Head of Sales | Established | 1 | Band A |
| 11 | Gareth Price | Warehouse Supervisor | Established | 3 | Band B |
| 12 | Hannah Scott | Warehouse Operative | Established | 11 | Band C |
| 14 | Priya Sharma | Accounts Assistant | Established | 4 | Band C |
| 7 | Sophie Turner | Sales Associate | Established | 6 | Band C |
| 10 | Chen Wei | Sales Associate | Established | 2 | Band C |
| 16 | James Reid | Sales Associate | Left | 6 | Band C |
Reasoning.
- Order of WHEN clauses. James Reid was hired in 2022 and left in 2025. Testing
TerminationDatefirst ensures he is Left. In the salary CASE, testing the highest band first means each laterWHENonly sees salaries below the previous threshold, soSalary >= 35000effectively means “35,000 to 69,999.99”. - Why CAST is needed. Without it,
COALESCE(ManagerID, 'None (top of hierarchy)')fails with Conversion failed when converting the varchar value ‘None (top of hierarchy)’ to data type int:inthas higher data type precedence thanvarchar, so SQL Server tries to convert the text to a number. - FullName alternatives.
FirstName + N' ' + LastNameworks here because neither column allows NULL.CONCAT_WSis more robust if that ever changes.
Show the stretch solution
SELECT ProductCode,
CONCAT(ProductName,
N' - ' + NULLIF(CONCAT_WS(N', ', Colour, N'size ' + Size), N'')
) AS CatalogueLabel
FROM Inventory.Product
WHERE Tags LIKE N'%waterproof%'
ORDER BY ProductCode;
| ProductCode | CatalogueLabel |
|---|---|
| FTW-001 | Ridge Walking Boot - Brown, size 9 |
| FTW-002 | Ridge Walking Boot - Brown, size 10 |
| JKT-001 | Storm Shell Waterproof Jacket - Navy, size M |
| JKT-002 | Storm Shell Waterproof Jacket - Red, size L |
| NAV-003 | Waterproof Map Case |
How it works, from the inside out
N'size ' + Sizeis NULL whenSizeis NULL (the+operator propagates NULL), so no orphan “size “ text appears.CONCAT_WS(N', ', Colour, …)joins whatever details exist and skips NULLs. When there are no details at all, it returns an empty string, not NULL.NULLIF(…, N'')turns that empty string into NULL.N' - ' + NULLis NULL, so the hyphen disappears when there are no details.- The outer
CONCATtreats that NULL as empty, leaving just the product name.
An equally valid solution uses a searched CASE: CASE WHEN Colour IS NULL AND Size IS NULL THEN ProductName ELSE … END. It is longer but may be easier for colleagues to read; clarity is a legitimate reason to choose it.
H. Knowledge check
-
Understanding. A colleague writes
WHERE Phone = NULLto find customers without a phone number and gets no rows, although some exist. Explain why, and give the correct condition. -
Query interpretation. How many of these orders does the query below return, and why might it not be what the author intended?
SELECT OrderID FROM Sales.SalesOrder WHERE OrderStatus = 'Pending' OR OrderStatus = 'Shipped' AND SalesChannel = 'Store'; -
Debugging. This query fails with Invalid column name ‘UnitMargin’. Why, and how do you fix it?
SELECT ProductCode, ListPrice - StandardCost AS UnitMargin FROM Inventory.Product WHERE UnitMargin > 100; -
Understanding. For the tent
TNT-001, whoseSizeis NULL, what doesISNULL(Size, N'One size fits all')return, and why? -
Debugging. What is wrong with this CASE expression?
CASE WHEN ListPrice >= 50 THEN 'Mid-range' WHEN ListPrice >= 150 THEN 'Premium' ELSE 'Budget' END
Show the answers
-
Any comparison with NULL — including
= NULL— evaluates to UNKNOWN, andWHEREkeeps only rows that are TRUE. The correct condition isWHERE Phone IS NULL. -
Because
ANDis evaluated beforeOR, the condition means Pending OR (Shipped AND Store). No store order is ever Shipped (store orders are Delivered on the spot), so it returns only the 4 pending orders. If the author meant “pending or shipped orders taken in store”, the condition needs parentheses:(OrderStatus = 'Pending' OR OrderStatus = 'Shipped') AND SalesChannel = 'Store'— which returns 0 rows, a useful fact in itself. -
WHEREis processed beforeSELECT, so the aliasUnitMargindoes not exist whenWHEREruns. Repeat the expression:WHERE ListPrice - StandardCost > 100. (Day 3 and Day 4 show how a derived table or CTE lets you name it once.) -
It returns
One size f.ISNULLreturns the data type of its first argument,nvarchar(10), so the replacement is silently truncated to ten characters.COALESCE(Size, N'One size fits all')returns the full text. -
The first
WHENmatches every price of £50 or more, including £599, and the first match wins — so nothing is ever labelled Premium. Test the highest threshold first:WHEN ListPrice >= 150 THEN 'Premium' WHEN ListPrice >= 50 THEN 'Mid-range' ELSE 'Budget'.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Invalid object name ‘Sales.Customer’. | The query window is connected to another database (often master), or the schema name is missing or misspelt | Run USE RetailLab; or pick RetailLab in the toolbar drop-down; always write schema.table |
| Invalid column name ‘UnitMargin’. | A SELECT alias was used in WHERE |
Repeat the expression in WHERE (see B5) |
| Incorrect syntax near ‘Connor’. or Unclosed quotation mark… | An apostrophe inside a string ended it early, as in 'O'Connor' |
Double the apostrophe: N'O''Connor' |
| Conversion failed when converting the varchar value ‘…’ to data type int. | CASE branches or COALESCE arguments mix text and numbers | Make every branch the same type, e.g. CAST(ManagerID AS varchar(10)) |
| Divide by zero error encountered. (error 8134) | A divisor evaluated to 0 | Divide by NULLIF(divisor, 0) |
| Query returns 0 rows but no error | = NULL, or a typo in a literal such as 'United Kingdon' |
Use IS NULL; check literal spelling against the data with SELECT DISTINCT |
Fewer rows than expected after <> |
NULLs are excluded by comparisons | Add OR column IS NULL if NULL rows belong in the result |
| Results appear in a different order from the lesson | No ORDER BY, or no unique tie-breaker |
Add ORDER BY with a unique final column |
| Red squiggles under valid names | IntelliSense cache is stale | Edit → IntelliSense → Refresh Local Cache (Ctrl + Shift + R) |
Cleanup. Today’s lesson is read-only and creates no objects, so there is nothing to clean up. Leave RetailLab exactly as it is: Day 2 starts from the same core data. You may close your query window without saving, or save it as day01_practice.sql for your own reference.
J. Completion checklist, recap and references
Completion checklist
- The validation query in section C returned 30, 40, 376 and 16.
- Worked example 3 returned 20 rows with parentheses and 41 without, and you can explain why.
- Your guided-lab query returns 23 rows, starting with Farah Ali and ending with Emma Wilson.
- Tasks 1–4 match the success criteria (10, 24, 8 and 16 rows).
- You answered the five knowledge-check questions before reading the answers.
Recap
- A table is a set of rows with no inherent order; primary keys identify rows and foreign keys connect tables.
SELECT … FROM schema.tablereturns columns and expressions; aliases name them;ORDER BYwith a unique tie-breaker fixes the order;DISTINCTremoves duplicate output rows across all selected columns.- Logical processing order is FROM → WHERE → SELECT → ORDER BY, which is why
WHEREcannot see SELECT aliases butORDER BYcan. NOTbinds beforeAND, andANDbeforeOR. Parenthesise mixed conditions.- NULL comparisons are UNKNOWN: use
IS [NOT] NULL, and remember that<>andNOT INquietly exclude NULLs. CASEreturns the first matching branch; order theWHENclauses deliberately and include anELSE.COALESCEreturns the first non-NULL value and sizes its result sensibly;ISNULLtakes the first argument’s type and can truncate;NULLIFturns a value into NULL, which makes division safe;CONCAT_WSskips NULLs.
Next: Day 2 — Aggregate functions and GROUP BY, including a short primer on joining order headers to order lines.
References (Microsoft Learn)
- SELECT (Transact-SQL) — including the logical processing order of the SELECT statement
- WHERE (Transact-SQL)
- ORDER BY clause (Transact-SQL)
- Operator precedence (Transact-SQL)
- LIKE (Transact-SQL)
- NULL and UNKNOWN (Transact-SQL)
- CASE (Transact-SQL)
- COALESCE (Transact-SQL)
- ISNULL (Transact-SQL)
- NULLIF (Transact-SQL)
- CONCAT_WS (Transact-SQL)
- Data types (Transact-SQL)
- Data type precedence (Transact-SQL)