The request

From Ryan Cooper, Regional Sales Manager
Your goal
For customers whose city is not NULL, build location_key as CONCAT(UPPER(TRIM(state)), '-', LOWER(TRIM(city))), for example 'NY-new york'. Return location_key, customers (number of customers) and raw_spellings (number of distinct raw city and state combinations that end up in that key). Keep only keys with more than one raw spelling. Sort by customers descending, then location_key ascending.
- Read from the
customerstablenot done yet - Show exactly
location_keycustomersraw_spellingsnot done yet - 47 rows, sorted as askednot done yet
Your query
Loading the editor…
Reference solution
| customer_id | first_name | last_name | city | |
|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
What Ryan gets, and why
Tables
Harbor & Pine store data, ready to query.
customers2,018 rows · 9 columns
customer_idintegerfirst_namevarcharlast_namevarcharemailvarcharcityvarcharstatevarcharsignup_datedatesegmentvarcharreferral_sourcevarchar
| customer_id | first_name | last_name | city | state | signup_date | segment | referral_source | |
|---|---|---|---|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco | CA | 2022-06-02 | consumer | search |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta | GA | 2022-06-02 | consumer | NULL |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles | CA | 2022-06-04 | business | NULL |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle | WA | 2022-06-06 | business | |
| 5 | Kyle | Jimenez | kjimenez27@yahoo.com | Indianapolis | IN | 2022-06-07 | consumer | NULL |
products120 rows · 8 columns
product_idintegernamevarcharcategoryvarcharsubcategoryvarcharunit_pricedecimalunit_costdecimallaunched_ondateis_activeboolean
| product_id | name | category | subcategory | unit_price | unit_cost | launched_on | is_active |
|---|---|---|---|---|---|---|---|
| 101 | Vale Velvet Loveseat | Furniture | Sofas | 1659.00 | NULL | 2023-09-11 | true |
| 102 | Alder Oak Coffee Table | Furniture | Tables | 919.00 | 503.50 | 2024-05-16 | true |
| 103 | Orchard Rattan Dining Chair | Furniture | Chairs | 789.00 | 429.87 | 2023-05-27 | true |
| 104 | Upland Oak Bookcase | Furniture | Storage | 429.00 | 214.70 | 2022-12-20 | true |
| 105 | Avery Leather Bed Frame | Furniture | Beds | 449.00 | 232.70 | 2021-08-31 | true |
orders12,000 rows · 7 columns
order_idintegercustomer_idintegerorder_datetimestampstatusvarcharchannelvarcharshipping_feedecimalcoupon_codevarchar
| order_id | customer_id | order_date | status | channel | shipping_fee | coupon_code |
|---|---|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web | 0.00 | NULL |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web | 0.00 | NULL |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace | 4.99 | WELCOME10 |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace | 4.99 | WELCOME10 |
| 1005 | 82 | 2023-01-02 11:43:36 | completed | web | 0.00 | WELCOME10 |
order_items30,338 rows · 6 columns
order_item_idintegerorder_idintegerproduct_idintegerquantityintegerunit_pricedecimaldiscount_pctdecimal
| order_item_id | order_id | product_id | quantity | unit_price | discount_pct |
|---|---|---|---|---|---|
| 1 | 1001 | 121 | 9 | 31.99 | 0.00 |
| 2 | 1001 | 154 | 3 | 11.99 | 0.00 |
| 3 | 1002 | 154 | 4 | 11.99 | 0.00 |
| 4 | 1002 | 194 | 1 | 149.00 | 0.00 |
| 5 | 1002 | 193 | 2 | 19.99 | 0.00 |
returns1,673 rows · 5 columns
return_idintegerorder_item_idintegerreturned_ondatereasonvarcharrefund_amountdecimal
| return_id | order_item_id | returned_on | reason | refund_amount |
|---|---|---|---|---|
| 1 | 14 | 2023-01-10 | damaged | 83.69 |
| 2 | 42 | 2023-01-10 | wrong_item | 19.99 |
| 3 | 105 | 2023-01-14 | wrong_item | 10.99 |
| 4 | 24 | 2023-01-16 | changed_mind | 26.99 |
| 5 | 126 | 2023-01-16 | late | 20.99 |
employees60 rows · 8 columns
employee_idintegerfirst_namevarcharlast_namevarchardepartmentvarchartitlevarcharmanager_idintegerhire_datedatesalaryinteger
| employee_id | first_name | last_name | department | title | manager_id | hire_date | salary |
|---|---|---|---|---|---|---|---|
| 1 | Catherine | Hale | Executive | Chief Executive Officer | NULL | 2016-03-01 | 285000 |
| 2 | Robert | Kim | Executive | Chief Operating Officer | 1 | 2017-01-09 | 235000 |
| 3 | Megan | Collins | Sales | Head of Sales | 1 | 2018-04-16 | 172000 |
| 4 | Chloe | Gutierrez | Sales | Account Executive | 3 | 2020-01-21 | 72000 |
| 5 | Frank | Bell | Sales | Account Executive | 3 | 2023-05-21 | 73000 |
web_sessions40,000 rows · 7 columns
session_idintegercustomer_idintegerstarted_attimestampdevicevarcharsourcevarcharpages_viewedintegerconvertedboolean
| session_id | customer_id | started_at | device | source | pages_viewed | converted |
|---|---|---|---|---|---|---|
| 1 | NULL | 2023-01-01 23:02:24 | mobile | paid | 1 | false |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid | 1 | false |
| 3 | NULL | 2023-01-01 16:55:46 | desktop | direct | 7 | false |
| 4 | NULL | 2023-01-01 12:43:28 | mobile | organic | 3 | false |
| 5 | NULL | 2023-01-01 13:06:21 | mobile | social | 2 | false |
marketing_spend3,276 rows · 3 columns
spend_datedatechannelvarcharamountdecimal
| spend_date | channel | amount |
|---|---|---|
| 2023-01-01 | paid | 492.99 |
| 2023-01-01 | 37.38 | |
| 2023-01-01 | social | 200.53 |
| 2023-01-02 | paid | 709.13 |
| 2023-01-02 | 64.13 |
The lesson
Chapter 6 cleaned one column: UPPER(TRIM(state)). Real reports usually group on several messy
columns at once, so you need a key: one standardized value that identifies the thing you are
counting, built from cleaned parts.
Building a key
A key has three ingredients: clean each part, pick one case, and join the parts with a separator that cannot appear inside them.
CONCAT(UPPER(TRIM(state)), '-', LOWER(TRIM(city))) -- 'NY-new york'
TRIM drops leading and trailing spaces, UPPER and LOWER fix casing, and CONCAT glues the
pieces. Lowercase is a fine choice for the city part: it is a key, not a display label. DuckDB has no
INITCAP, so pretty title case is a job for the report layer.
Why include the state? Because Harbor & Pine has customers in both Portland, Oregon and Portland, Maine. Keyed on city alone they would merge into one location.
Measuring the mess
Count the raw values inside each clean group to see how much the cleaning did:
SELECT
CONCAT(UPPER(TRIM(state)), '-', LOWER(TRIM(city))) AS location_key,
COUNT(*) AS customers,
COUNT(DISTINCT city || '|' || state) AS raw_spellings
FROM customers
WHERE city IS NOT NULL
GROUP BY 1;
On this data the 2,004 customers with a city have 168 distinct raw city and state pairs, which
collapse into 62 locations. NY-new york has 181 customers spread over 5 spellings, and TX-houston
has 8. The 14 customers with a NULL city are left out on purpose: a NULL is missing data, not a
spelling, and it gets its own treatment in level 3.
Common mistakes
- No separator.
COUNT(DISTINCT city || state)returns 163 instead of 168, because'Houston' || ' TX'and'Houston ' || 'TX'produce the same string. Put a character like|between parts, or count the pair directly withCOUNT(DISTINCT (city, state)). - Cleaning in SELECT, grouping raw. If you
GROUP BY city, stateand only clean in SELECT, you get several rows that display the same key, each with a partial count. - Too much cleaning. Removing every space would turn “San Jose” into “sanjose”. Trim the ends, keep the inside.
On the job
Keep a list of the raw spellings you collapsed and send it to whoever owns the CRM form. Fixing the input (a dropdown instead of free text) beats cleaning the same mess in every query forever. In an interview, “how would you count customers per city?” is a test of whether you check the text first.
In SQL Server
TRIM, UPPER, LOWER and CONCAT exist under the same names (TRIM since SQL Server 2017; older
code uses LTRIM(RTRIM(city))). Use + or CONCAT to join strings. Watch LEN: it ignores trailing
spaces, so LEN('Boston ') is 6, while DATALENGTH counts bytes.
SELECT CONCAT(UPPER(TRIM(state)), '-', LOWER(TRIM(city))) AS location_key, COUNT(*) AS customers
FROM customers WHERE city IS NOT NULL
GROUP BY CONCAT(UPPER(TRIM(state)), '-', LOWER(TRIM(city))); Lantern lit!
Practised: data cleaningtrimupperlowerconcatgrouping key
- Solved
- No hints
- First run
