The request

From Logan Brooks, Email Marketing Specialist
Your goal
Set result to a DataFrame of customers with no rows in orders: columns customer_id, first_name, last_name, email, sorted by customer_id ascending, index reset.
- Use the
customersDataFramenot done yet - Use the
ordersDataFramenot done yet - Show exactly
customer_idfirst_namelast_nameemailnot done yet
Your code
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 Logan gets, and why
DataFrames
Loaded for you as pandas DataFrames, plus pd and np.
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 | NaN |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles | CA | 2022-06-04 | business | NaN |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle | WA | 2022-06-06 | business | |
| 5 | Kyle | Jimenez | kjimenez27@yahoo.com | Indianapolis | IN | 2022-06-07 | consumer | NaN |
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 | NaN |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web | 0.00 | NaN |
| 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 |
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 | NaN | 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 |
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 | NaN | 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 | NaN | 2023-01-01 23:02:24 | mobile | paid | 1 | false |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid | 1 | false |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct | 7 | false |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic | 3 | false |
| 5 | NaN | 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
Left joins keep everything on the left
An inner join drops rows that have no match. When the unmatched rows are what you’re looking for, that’s exactly wrong. A left join keeps every row of the left table and fills the right table’s columns with NaN where nothing matched:
customers.merge(orders, on="customer_id", how="left")
Customers who never ordered appear once, with NaN in every order column. Customers with five orders appear five times. That second fact matters: a left join can make the table longer, so it’s not a way to “add information” to customers without changing their count.
indicator: where did each row come from?
indicator=True adds a _merge column with one of three values:
"both": the row matched."left_only": only in the left table."right_only": only in the right (possible withhow="outer").
ordered = orders[["customer_id"]].drop_duplicates()
merged = customers.merge(ordered, on="customer_id", how="left", indicator=True)
merged["_merge"].value_counts()
never = merged[merged["_merge"] == "left_only"]
orders[["customer_id"]] is a one-column table with one row per order, so a customer with five
orders appears five times. .drop_duplicates() removes repeated rows and keeps the first of each,
leaving every customer ID once:
orders[["customer_id"]].drop_duplicates() # 1,771 rows: each customer who ordered, once
De-duplicating like this before the merge keeps the merged table at exactly one row per customer, so
no fan-out at all. (You’ll see drop_duplicates in more depth in the cleaning chapter.)
indicator is also the best tool for auditing a merge you didn’t expect to lose rows on: run it
with how="outer" and look at what’s left_only and right_only.
The isin shortcut
When you only need the left table’s rows, an anti-join with isin is shorter and doesn’t create
a big intermediate table:
customers[~customers["customer_id"].isin(orders["customer_id"])]
Read it as “customers whose ID is not in the orders’ customer IDs”. Both approaches give the same
rows. Use isin for a quick anti-join and the merge with indicator when you also want to inspect
matched rows.
Checking NaN after a left join
Another common way to find unmatched rows is to test a right-hand column for missing values.
.isna() returns True where a value is missing (NaN, pandas’ marker for “no value”):
merged = customers.merge(orders, on="customer_id", how="left")
merged[merged["order_id"].isna()]
This works when the column you test is never missing for matched rows. order_id qualifies, but a
column like coupon_code, which is often empty on real orders, would wrongly flag lots of buyers.
Test a key column, or use indicator.
Who are these customers?
Some are recent signups who simply haven’t bought yet, and some are the duplicate customer records you’ll meet in the cleaning chapter: the same person signed up twice and placed orders only under the first ID. Before Logan emails everyone on this list, those duplicates should be removed, or a loyal customer gets a “you haven’t ordered yet” email.
Common mistakes
- Using an inner join and wondering why the result is empty.
- Testing a column that has gaps of its own for NaN to detect “no match”.
- Forgetting that a left join to a “many” table repeats left rows.
If you know SQL
This is a LEFT JOIN ... WHERE o.customer_id IS NULL, or WHERE customer_id NOT IN (SELECT customer_id FROM orders).
Lantern lit!
Practised: left joinindicatoranti joinisin
- Solved
- No hints
- First run
