Filtering rows — asking every row the same question
Keeping only the rows that answer yes: boolean masks, combining them with &, | and ~, isin, between and the .str methods, filtered rows with .loc — and changing just those rows without the chained assignment that silently does nothing.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
The shop's manager sends a message: "Which orders from the north branch were for more than three items? I want to see them."
With the eight orders from the last chapter you could answer by eye. Run a finger down the branch column, stop at every north, glance across at quantity. Three rows. Done.
Now make it the real file — twelve thousand orders, a year of them. Your eye no longer works, and the first idea most Python programmers have is a loop:
import pandas as pd
orders = pd.read_csv("orders.csv")
keep = []
for i in range(len(orders)):
row = orders.iloc[i]
if row["branch"] == "north" and row["quantity"] > 3:
keep.append(int(row["order_id"]))
print(keep)[1001, 1005, 1008]The answer is right. But look at what it cost: a counter, an empty list, an if, an append, and the table taken apart one row at a time. And the result is not a table any more — it is a bare list of order numbers. To show the manager the products and prices too, you would loop again. On twelve thousand rows this is also slow, because every orders.iloc[i] builds a fresh Series just to read two values from it.
Pandas has a different way to ask, and it is the most common thing you will ever do with a DataFrame: ask every row the same yes-or-no question at once, then keep the rows that said yes. That is filtering, and it is this whole chapter. The loop above becomes one line, the result stays a table, and it reads almost like the manager's sentence.
By the end of this chapter you can
- Turn a question into a boolean mask — a column of
True/False, one per row — and use it to keep rows, or to count them withmask.sum()andmask.mean() - Combine conditions with
&,|and~, and explain why the parentheses are compulsory - Use
isin,betweenand the.strmethods for lists, ranges and text patterns - Filter rows and pick columns in one step with
.loc[mask, columns] - Change values in the filtered rows safely with
.loc[mask, column] = value, and recognise theChainedAssignmentErrorthat the unsafe way produces in pandas 3
Prerequisites: Selecting columns and rows.
Make the file first
This chapter uses the same orders.csv as the previous one. If you do not have it, create it in your project folder with exactly these nine lines:
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0Before you write the code
A filter is a question written in code. Most filtering bugs are not pandas bugs — they are a question that was never pinned down, or a column that does not hold what you assumed. So before the first line of filtering, do these five things.
1. Write the question as conditions, one per column. Take the manager's sentence apart:
- "orders from the north branch" → column
branch, test equals, value"north" - "more than three items" → column
quantity, test greater than, value3 - "I want to see them" → no test; keep the whole row
Two things this list forces you to decide. "More than three" is > 3, not >= 3 — an order of exactly three items does not count. And the two conditions are joined by and: an order must pass both. Those two decisions are the whole filter; the code is just their spelling.
2. Check the input — shape, types, and the exact values you will compare against.
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectquantity is int64, so > 3 will compare numbers. If it had come in as str — because someone typed "3 pcs" in one cell — the comparison would fail with a TypeError, and you want to know that now, not halfway through. branch is str, so it will be compared as text, and text comparison is exact: "north", "North" and "north " are three different values. So look at the values that really exist:
print(orders["branch"].unique())<StringArray>
['north', 'south', 'east']
Length: 3, dtype: strThree branches, spelled one way each, no stray spaces. Now "north" in the code is guaranteed to match something.
3. Sketch the output before you produce it. On a small file, answer the question by hand first. The north-branch orders are 1001 (12 items), 1003 (2), 1005 (30) and 1008 (4). More than three items: 1001, 1005, 1008. So the expected result is 3 rows, all 7 columns, and their order numbers are known. If the code later returns 4 rows, or 0, you will know immediately that the code is wrong rather than trusting it. On a large file you cannot do all of this by hand, but you can still estimate: "the north branch takes about half the orders, so the answer should be well under half the rows."
4. Choose the tool from the shape of the condition.
- one comparison → a mask:
df["col"] > value - several conditions → masks joined with
&(and),|(or),~(not) - "one of these values" →
df["col"].isin([...]) - "between two values" →
df["col"].between(low, high) - a pattern inside text →
df["col"].str.contains(...),.str.startswith(...) - rows and only some columns →
df.loc[mask, ["col1", "col2"]] - change values in those rows →
df.loc[mask, "col"] = new_value
The manager's question is two comparisons joined by and, so: two masks and &.
5. Decide how you will check the answer. After filtering, three cheap checks catch nearly everything: the row count matches your sketch; the condition really holds in every surviving row ((result["quantity"] > 3).all() should print True); and nothing you expected is missing (look for 1008 in the result).
Now the code — one idea at a time.
A comparison gives a mask
Compare a column with a value, and pandas does not give you one True or False. It compares every row and gives you a whole column of answers:
big = orders["quantity"] > 5
print(big)0 True
1 False
2 False
3 True
4 True
5 False
6 True
7 False
Name: quantity, dtype: boolThis is a Series of bool — a boolean mask. It has the same index as orders (0 to 7), one answer per row: row 0 has 12 items, so True; row 1 has 5, and 5 is not greater than 5, so False.
Why does pandas work this way, instead of making you loop? Because a column is stored as one block of numbers of a single type, and comparing a whole block against 5 is one operation done in fast compiled code. Your loop asked Python to look at each row separately, eight times — or twelve thousand times. The mask asks once.
Every comparison operator works the same way: ==, !=, <, <=, >, >=. They work on text columns too — orders["branch"] == "north" is a mask with True in rows 0, 2, 4 and 7.
Nothing has been filtered yet. A mask is only the answers. The next step is to use them.
The mask keeps rows
Put the mask inside square brackets, and pandas keeps the rows where the mask is True:
print(orders[big])order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
3 1004 2024-03-02 east bottle accessories 7 120.0
4 1005 2024-03-03 north eraser stationery 30 8.0
6 1007 2024-03-04 east pen stationery 20 15.0Four rows out of eight. Read the index down the left: 0, 3, 4, 6. The surviving rows keep their original labels. Filtering does not renumber anything — row 3 is still row 3, the same order 1004 it was in the full table. That turns out to matter, and we will come back to it.
How does pandas line the mask up with the rows? Not by position — by index label. The mask's label 0 decides row 0, label 3 decides row 3. Here the two indexes are identical, because the mask was built from orders itself, so this is invisible. It becomes visible only when you use a mask built from a different table — that is in "When it breaks".
You will usually see the mask written inline, without a name:
print(orders[orders["branch"] == "north"])order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
2 1003 2024-03-02 north bag accessories 2 850.0
4 1005 2024-03-03 north eraser stationery 30 8.0
7 1008 2024-03-04 north bottle accessories 4 120.0Read it from the inside out: `orders["branch"] == "north"` builds the mask, `orders[ … ]` keeps the True rows. Naming the mask (big = …) is worth it when the condition is long or when you use it twice; inline is fine for short ones.
Counting with a mask, without filtering
Often the manager does not want the rows, only a number: how many orders were bigger than five items? You do not need to filter for that. In arithmetic, True counts as 1 and False as 0, so:
print(big.sum())
print(big.mean())4
0.5sum() adds up the ones: 4 orders. mean() divides by the number of rows: 4 out of 8 is 0.5, so half the orders were bigger than five items. "How many" is .sum() of a mask; "what share" is .mean() of a mask. Both are one line and neither builds a new table.
And when you want a total over the filtered rows — the number of items in those big orders — filter first, then pick the column, then sum:
print(orders[big]["quantity"].sum())6912 + 7 + 30 + 20 = 69. Reading a column of a filtered table, like this, is fine. Writing into one this way is not — that is the last section of the chapter.
Several conditions: &, |, ~
The manager's question has two conditions. Pandas joins masks with three operators:
&means and — the row is kept when both masks areTrue|means or — the row is kept when at least one mask isTrue~means not — it flips everyTruetoFalseand back
Here is the manager's question, finally:
north_big = orders[(orders["branch"] == "north") & (orders["quantity"] > 3)]
print(north_big)order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
4 1005 2024-03-03 north eraser stationery 30 8.0
7 1008 2024-03-04 north bottle accessories 4 120.0Three rows: 1001, 1005, 1008 — exactly the sketch from the planning step. Now the check from step 5:
print(len(north_big))
print((north_big["quantity"] > 3).all())
print((north_big["branch"] == "north").all())3
True
TrueCount matches, and both conditions hold in every surviving row. .all() asks "is every value in this mask True?" — it is the cheapest way to prove a filter did what you meant.
An or question: orders from the east branch, or any order of something costing 850 or more.
print(orders[(orders["branch"] == "east") | (orders["price"] >= 850)])order_id date branch product category quantity price
2 1003 2024-03-02 north bag accessories 2 850.0
3 1004 2024-03-02 east bottle accessories 7 120.0
5 1006 2024-03-03 south bag accessories 1 850.0
6 1007 2024-03-04 east pen stationery 20 15.0Four rows: the two east-branch orders, and the two bag orders from north and south. A row that passed both (none here) would appear once, not twice — a filter only ever keeps or drops a row.
A not question: everything except stationery.
print(orders[~(orders["category"] == "stationery")])order_id date branch product category quantity price
2 1003 2024-03-02 north bag accessories 2 850.0
3 1004 2024-03-02 east bottle accessories 7 120.0
5 1006 2024-03-03 south bag accessories 1 850.0
7 1008 2024-03-04 north bottle accessories 4 120.0~ turns every True into False and back. For a single equality you could write != instead, and that is simpler; ~ earns its place when the thing you are negating is a longer condition or an isin (you will see that shortly).
Why the parentheses are compulsory
Every condition above sits inside its own parentheses. That is not style. Remove them and the code breaks:
print(orders[orders["quantity"] > 3 & orders["price"] < 100])TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]What you see above is the last line of the traceback, the line that names the error; the lines above it only say where it happened. The reason is operator precedence — the order in which Python applies operators. In Python, & binds more tightly than > and <. That was decided long before pandas existed, because & was designed for whole numbers (bitwise "and"), where it should happen first. So Python reads the line as:
orders["quantity"] > (3 & orders["price"]) < 100It tries to compute 3 & orders["price"] first — a bitwise "and" between the number 3 and a column of decimals — and that is what the error is about. The message mentions rand_ (the "right-hand and") and an array, but the cause is always the same: a missing pair of parentheses.
The error is the lucky outcome. Wrap only the first condition and forget the second, and there is no error at all:
print(len(orders[(orders["quantity"] > 3) & orders["price"] < 100]))
print(len(orders[(orders["quantity"] > 3) & (orders["price"] < 100)]))8
4The first line is read as ((quantity > 3) & price) < 100. The & mixes a mask with the prices, and every result happens to be below 100, so the "filter" keeps all eight rows. The correct filter keeps four. Same columns, same numbers, one missing pair of parentheses — and the wrong version runs without a sound. Wrap every condition in its own parentheses, every time. Then neither the error nor the silent version can happen.
Why not and?
The other natural instinct is to write the English word:
print(orders[(orders["branch"] == "north") and (orders["quantity"] > 3)])ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().Python's and, or and not need a single True or False on each side — they were built for if statements. To use and, Python asks the first mask "are you true?", and a column of eight answers cannot reply with one. Is it true if any value is True? If all are? Pandas refuses to guess, and the error lists the ways you could decide (a.any(), a.all()). But you did not want one answer; you wanted row-by-row "and". That is &. The rule to remember: inside a filter, and/or/not become &/|/~.
One of several values: isin
Orders from the south or east branch. You could write two equalities joined with |. With five branches that becomes five comparisons. isin takes a list and asks "is this row's value in the list?":
print(orders[orders["branch"].isin(["east", "south"])])order_id date branch product category quantity price
1 1002 2024-03-01 south notebook stationery 5 60.0
3 1004 2024-03-02 east bottle accessories 7 120.0
5 1006 2024-03-03 south bag accessories 1 850.0
6 1007 2024-03-04 east pen stationery 20 15.0And here is where ~ earns its keep — every branch except north and east is ~ in front of an isin:
print(orders[~orders["branch"].isin(["north", "east"])]["order_id"].tolist())[1002, 1006]Two orders, both from the south branch. Note that ~orders["branch"].isin([...]) needs no extra parentheses: ~ applies to the result of the method call, and there is no & or | to fight with. As soon as you combine it with another condition, the parentheses come back: (~orders["branch"].isin([...])) & (orders["quantity"] > 3).
A range: between
Orders priced from 15 to 120. That is two comparisons, >= 15 and <= 120. between says it in one:
print(orders[orders["price"].between(15, 120)])order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
1 1002 2024-03-01 south notebook stationery 5 60.0
3 1004 2024-03-02 east bottle accessories 7 120.0
6 1007 2024-03-04 east pen stationery 20 15.0
7 1008 2024-03-04 north bottle accessories 4 120.0between includes both ends by default — the pens at exactly 15.0 and the bottles at exactly 120.0 are in. That matches how people usually say "from 15 to 120", but check it against the question. If the question means strictly between, say so:
print(orders["price"].between(15, 120, inclusive="neither").sum())1Only the notebook at 60.0 is strictly between. inclusive also accepts "left" and "right" for half-open ranges — the usual choice for money bands, where you want 0–100, 100–200 and so on without a boundary value landing in two bands.
Text patterns: the .str methods
Equality on text is exact. The .str accessor gives a text column the string methods you know from Python, applied to every row, and each returns a mask.
Products whose name starts with "b":
print(orders[orders["product"].str.startswith("b")])order_id date branch product category quantity price
2 1003 2024-03-02 north bag accessories 2 850.0
3 1004 2024-03-02 east bottle accessories 7 120.0
5 1006 2024-03-03 south bag accessories 1 850.0
7 1008 2024-03-04 north bottle accessories 4 120.0Products containing the letter "o", whatever the case:
print(orders[orders["product"].str.contains("O", case=False)])order_id date branch product category quantity price
1 1002 2024-03-01 south notebook stationery 5 60.0
3 1004 2024-03-02 east bottle accessories 7 120.0
7 1008 2024-03-04 north bottle accessories 4 120.0There is no capital O anywhere in the data, yet the notebook and the two bottles are found, because case=False ignores case. Without it, .str.contains("O") would find nothing.
Exact equality has no such option. This is the most common "my filter returns nothing" bug:
print(orders[orders["branch"] == "North"])Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []No error, no rows. The table is not wrong and pandas is not wrong — there is simply no branch spelled "North" with a capital letter. This is why step 2 of the plan printed unique(): the values you compare against should be copied from what is actually in the column, not typed from memory.
Text dates compare correctly — when they are ISO
The date column came in as str. Can you filter it with >=? Orders from 3 March onwards:
print(orders["date"] >= "2024-03-03")0 False
1 False
2 False
3 False
4 True
5 True
6 True
7 True
Name: date, dtype: boolIt works, but understand why. These are strings, so >= compares them as text, character by character, the way a dictionary is ordered. That gives the right answer only because the dates are written year-month-day with zero padding (2024-03-03), so text order happens to equal time order. A column written 3/3/2024 would compare wrongly — "10/1/2024" sorts before "9/1/2024" because "1" comes before "9". For anything beyond simple cut-offs, convert to real dates with pd.to_datetime, covered in the chapter on adding columns.
Rows and columns together: .loc[mask, columns]
The filters so far kept every column. The manager wanted the north-branch orders, but the report only needs the product and quantity. You met .loc[rows, columns] in the last chapter, with labels. The rows part also accepts a mask:
print(orders.loc[orders["branch"] == "north", ["product", "quantity"]])product quantity
0 pen 12
2 bag 2
4 eraser 30
7 bottle 4One step, one object, and the reader of your code sees the whole question — which rows, which columns — in one place. .loc[mask, "quantity"] (a single column name, not a list) gives you a Series instead, ready for arithmetic:
print(orders.loc[orders["branch"] == "north", "quantity"].sum())4812 + 2 + 30 + 4 = 48 items sold by the north branch.
query(): the same filter as a sentence
There is a second spelling for filters, query(), which takes the condition as a string:
print(orders.query("branch == 'north' and quantity > 3"))order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
4 1005 2024-03-03 north eraser stationery 30 8.0
7 1008 2024-03-04 north bottle accessories 4 120.0The same three rows. Inside the string you write plain column names, and and/or/not are allowed, because query parses the string itself instead of handing it to Python's operators. To use a Python variable, prefix it with @:
min_qty = 5
print(orders.query("quantity >= @min_qty")["order_id"].tolist())[1001, 1002, 1004, 1005, 1007]query reads nicely for long conditions. Its costs: a typo inside the string is only discovered when the line runs, your editor cannot help you inside it, and column names with spaces need backticks. This course uses masks as the main tool, because masks are what everything else in pandas — .loc, assignment, counting — is built on. Know query so you can read it in other people's code.
The filtered table keeps its old labels
Back to the index. Save the north-branch orders and look at them:
north = orders[orders["branch"] == "north"]
print(north.index.tolist())[0, 2, 4, 7]Labels 0, 2, 4, 7. So what is north.loc[1]? There is no label 1 in this table — order 1002 came from the south branch and was filtered out:
print(north.loc[1])KeyError: 1.loc looks up a label, and label 1 does not exist here. If you meant "the second north-branch order", that is a position, and positions are .iloc:
print(north.iloc[1])order_id 1003
date 2024-03-02
branch north
product bag
category accessories
quantity 2
price 850.0
Name: 2, dtype: objectThe second row by position is the bag order — and its Name is 2, its label from the original table. This is exactly the loc/iloc distinction from the last chapter, and filtering is where it bites, because after a filter labels and positions stop matching.
Keeping the labels is deliberate: it lets you trace any row in a result back to the original table. When you no longer need that — say the filtered table is the final report — renumber it:
print(north.reset_index(drop=True))order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
1 1003 2024-03-02 north bag accessories 2 850.0
2 1005 2024-03-03 north eraser stationery 30 8.0
3 1008 2024-03-04 north bottle accessories 4 120.0drop=True throws the old labels away. Without it, they would be kept as a new column called index, which is rarely what you want in a report.
An empty result is not an error
none = orders[orders["branch"] == "west"]
print(none)
print(none.shape)
print(none.empty)Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []
(0, 7)
TrueThere is no west branch, so no rows — and no error. That is correct behaviour (zero is a valid answer), but it means a misspelt filter fails silently. Sums over an empty result are 0 and means are nan, which can travel a long way into a report before anyone notices. When a filter feeds something important, check result.empty or the row count, and say so out loud in the output: "No orders from the west branch." is better than a blank table.
Changing the filtered rows: .loc[mask, column] = value
Sometimes you filter in order to change something. The pens sold by the east branch were mispriced; they should have been 13.5, not 15.0. The tempting code does the filter and then picks the column — two pairs of brackets, the way you read values earlier:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders[orders["branch"] == "east"]["price"] = 13.5
print(orders["price"].tolist())[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignmentRead the prices: nothing changed. And pandas warned you, at length. (In a terminal the warning goes to stderr, so it appears above the list, at the moment the assignment line runs, and it starts with the file name and line number. Here it is shown after the output, without them.) This is chained assignment — two indexing steps, orders[mask] and then ["price"], with the assignment on the end of the chain. The first step, orders[mask], builds a new table holding the east-branch rows. The second step sets prices in that new table. Then the new table is thrown away, because nothing holds on to it. orders itself was never touched.
In pandas 3 this is guaranteed behaviour, called Copy-on-Write: any table you get by indexing behaves as its own copy, so writing into it can never reach back into the original. The ChainedAssignmentError is a warning, not an exception — the program keeps running — which is exactly why it is dangerous: a script with this line finishes "successfully" with the old prices still in place.
(If you read older tutorials, you will see this situation described with a SettingWithCopyWarning and a note that it "may or may not" work. In pandas 3 the answer is simple: it never works, so never write it.)
The fix is the warning's own advice: one indexing step, .loc[rows, column], with the assignment on it:
orders.loc[orders["branch"] == "east", "price"] = 13.5
print(orders["price"].tolist())[15.0, 60.0, 850.0, 13.5, 8.0, 850.0, 13.5, 120.0]Wait — that changed both east-branch orders, the pen and the bottle. The bottle was not mispriced. The code did exactly what it said; the question in the code was wrong. This is step 1 of the plan coming back: "pens sold by the east branch" is two conditions, and only one was written. Start again from a fresh copy of the file and write both:
import pandas as pd
orders = pd.read_csv("orders.csv")
fix = (orders["branch"] == "east") & (orders["product"] == "pen")
print(fix.sum())
orders.loc[fix, "price"] = 13.5
print(orders.loc[orders["branch"] == "east", ["product", "price"]])1
product price
3 bottle 120.0
6 pen 13.5Two habits are visible here. The mask gets a name (fix) because it is used for something important; and before writing, fix.sum() says how many rows are about to change — one, which is what we expected. Count before you overwrite, every time.
To read,df[mask]["col"]is fine. To write, always usedf.loc[mask, "col"] = value. The chained form raises no exception, and the change silently disappears.
Saving a filtered table to work on is fine
What about this?
import pandas as pd
orders = pd.read_csv("orders.csv")
east = orders[orders["branch"] == "east"]
east["price"] = 0.0
print(east["price"].tolist())
print(orders["price"].tolist())[0.0, 0.0]
[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]No warning, and that is right. Here you meant to make a separate table: you named it east, and changing it changes east only. orders is untouched. Copy-on-Write makes this behaviour predictable — a table you got by filtering is always independent of the original. The bug is only ever the chained form, where the intermediate table has no name and the change disappears with it.
A complete example
The manager now wants a short report. report.py:
import pandas as pd
orders = pd.read_csv("orders.csv")
# Check what arrived before asking it anything.
print("Rows, columns:", orders.shape)
print("Branches:", list(orders["branch"].unique()))
print()
# 1. The original question: north-branch orders of more than three items.
north_big = orders.loc[
(orders["branch"] == "north") & (orders["quantity"] > 3),
["order_id", "product", "quantity"],
]
print("North orders over 3 items:")
print(north_big.reset_index(drop=True))
print()
# 2. Counting with masks: no new table needed.
accessories = orders["category"] == "accessories"
print("Accessory orders :", accessories.sum())
print("Share of all orders:", accessories.mean())
print("Accessory items :", orders.loc[accessories, "quantity"].sum())
print()
# 3. Outside north, cheap items (under 100).
cheap_outside = orders[
(orders["branch"] != "north") & (orders["price"] < 100)
]
print("Cheap orders outside north:", cheap_outside["order_id"].tolist())
# 4. A branch we do not have: say so instead of printing an empty table.
west = orders[orders["branch"] == "west"]
if west.empty:
print("No orders from the west branch.")Rows, columns: (8, 7)
Branches: ['north', 'south', 'east']
North orders over 3 items:
order_id product quantity
0 1001 pen 12
1 1005 eraser 30
2 1008 bottle 4
Accessory orders : 4
Share of all orders: 0.5
Accessory items : 14
Cheap orders outside north: [1002, 1007]
No orders from the west branch.Why it is written this way:
- Check first, then ask.
shapeand the list of branches are printed before any filter. If the file had arrived withNorthcapitalised, the branches line would show it before the report printed an empty table. .loc[mask, columns]for the main question. The rows and the columns of the answer are decided in one place, andreset_index(drop=True)renumbers the result because this is the final report, not a step on the way.- A named mask, used three times.
accessoriesis computed once and then counted (sum), measured (mean) and used to select a column. Naming a mask is how you avoid writing the same condition three times and getting one of them slightly different. - Every condition in its own parentheses, even in the short ones, so adding a third condition later cannot trigger the precedence error.
- The empty case is handled out loud. Zero rows is a valid answer, and the report says it in words.
When it breaks
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all(). You used and, or or not between two masks — or an if on a whole mask. Python's keywords need one True/False, and a mask has one per row. Inside a filter, write &, |, ~. Inside an if, decide what you mean: if mask.any(): ("is at least one row true?") or if mask.all(): ("are all rows true?").
TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool] (or 'ror_', or [int64]) Missing parentheses around a condition next to & or |. Python applied & before >, so it tried a bitwise "and" between a number and a column. Put each condition in its own parentheses: (a > 3) & (b < 100).
The filter returns an empty table, with no error Your value does not occur in the column exactly as typed. Print df["col"].unique() — or better, list(df["col"].unique()), which shows each value inside quotes, so a trailing space in 'north ' becomes visible. Fix the data with df["col"].str.strip(), or match loosely with .str.contains(..., case=False).
KeyError: 1 (or another number) right after filtering You used .loc[n] on a filtered table as if it still had labels 0, 1, 2…. It keeps the original labels. Use .iloc[n] for "the n-th row", or reset_index(drop=True) first if the old labels are no longer useful.
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. You wrote df[mask]["col"] = value. It never changes df in pandas 3 — the warning is telling you the line did nothing. Write df.loc[mask, "col"] = value.
UserWarning: Boolean Series key will be reindexed to match DataFrame index. The mask was built from a different table than the one you are filtering — typically a mask from the full orders used on the filtered north. Pandas lines it up by label and silently drops labels it cannot find:
import pandas as pd
orders = pd.read_csv("orders.csv")
north = orders[orders["branch"] == "north"]
big = orders["quantity"] > 5
print(north[big])order_id date branch product category quantity price
0 1001 2024-03-01 north pen stationery 12 15.0
4 1005 2024-03-03 north eraser stationery 30 8.0
UserWarning: Boolean Series key will be reindexed to match DataFrame index.(In a terminal the warning appears above the table.) It happens to give a sensible answer here, but only by luck of the labels. Build the mask from the table you are filtering: north[north["quantity"] > 5].
Step 4 of 6 — Predict
Check your understanding
What is printed?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
mask = orders["quantity"] > 5
print(mask.sum(), len(orders[mask]))- A4 8
- B5 5
- C4 4
- DTrue 4
This is meant to keep north-branch orders of more than three items. What happens when it runs?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
north_big = orders[orders["branch"] == "north" & orders["quantity"] > 3]- AIt works and keeps orders 1001, 1005 and 1008
- BIt returns an empty table
- CA `TypeError`: without parentheses, `&` is applied before `==` and `>`
- DA `ValueError: The truth value of a Series is ambiguous`
You need to halve the price of every bag in orders itself. Which line does it?
- Aorders[orders["product"] == "bag"]["price"] = orders["price"] / 2
- Borders.loc[orders["product"] == "bag", "price"] = orders["price"] / 2
- Cbags = orders[orders["product"] == "bag"] bags["price"] = bags["price"] / 2
- Dorders["price"][orders["product"] == "bag"] /= 2
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Use the same orders.csv. Write filters.py that answers four questions, each printed under a short heading:
- How many orders were for 10 items or more? Print a number, not a table.
- Accessory orders outside the north branch — only
order_id,branch,productandprice, renumbered from 0. - Orders from the south or east branch whose price is under 100. Use
isinfor the branches. - Head office gives a 10% discount on every stationery order of 20 items or more. Set the
priceof exactly those rows to 90% of their current price, but first print how many rows you are about to change. Then printorder_id,quantityandpricefor all stationery orders.
Before writing any code, plan each question on paper: which columns, which tests, joined by what, and which rows you expect by looking at the file. When your program is right, it prints exactly this:
1. Orders of 10+ items:
3
2. Accessory orders outside north:
order_id branch product price
0 1004 east bottle 120.0
1 1006 south bag 850.0
3. South or east, price under 100:
order_id date branch product category quantity price
1 1002 2024-03-01 south notebook stationery 5 60.0
6 1007 2024-03-04 east pen stationery 20 15.0
4. Stationery discount:
Rows to change: 2
order_id quantity price
0 1001 12 15.0
1 1002 5 60.0
4 1005 30 7.2
6 1007 20 13.5Then break it on purpose, once each:
- Remove the parentheses in question 3. Which error appears?
- Write question 4 as
orders[mask]["price"] = .... What does pandas print, and did any price change? - Change
"east"to"East"in question 3. What happens, and why is that worse than an error?
Solution
Plan first. Each question, taken apart:
quantity >= 10, one condition. By eye: 12, 30 and 20, so 3.category == "accessories"andbranch != "north". By eye: 1004 (east, bottle) and 1006 (south, bag).branchin south/east andprice < 100. By eye: 1002 (notebook, 60.0) and 1007 (pen, 15.0).category == "stationery"andquantity >= 20. By eye: 1005 (eraser, 30) and 1007 (pen, 20), so 2 rows.
Question 4 needs care: "20 items or more" is >= 20, so order 1007, with exactly 20 items, is included. And it writes, so it uses .loc[mask, "price"] with a named mask, counted first.
filters.py:
import pandas as pd
orders = pd.read_csv("orders.csv")
print("1. Orders of 10+ items:")
print((orders["quantity"] >= 10).sum())
print()
print("2. Accessory orders outside north:")
outside = orders.loc[
(orders["category"] == "accessories") & (orders["branch"] != "north"),
["order_id", "branch", "product", "price"],
]
print(outside.reset_index(drop=True))
print()
print("3. South or east, price under 100:")
cheap = orders[
(orders["branch"].isin(["south", "east"])) & (orders["price"] < 100)
]
print(cheap)
print()
print("4. Stationery discount:")
discount = (orders["category"] == "stationery") & (orders["quantity"] >= 20)
print("Rows to change:", discount.sum())
orders.loc[discount, "price"] = orders.loc[discount, "price"] * 0.9
print(orders.loc[orders["category"] == "stationery", ["order_id", "quantity", "price"]])1. Orders of 10+ items:
3
2. Accessory orders outside north:
order_id branch product price
0 1004 east bottle 120.0
1 1006 south bag 850.0
3. South or east, price under 100:
order_id date branch product category quantity price
1 1002 2024-03-01 south notebook stationery 5 60.0
6 1007 2024-03-04 east pen stationery 20 15.0
4. Stationery discount:
Rows to change: 2
order_id quantity price
0 1001 12 15.0
1 1002 5 60.0
4 1005 30 7.2
6 1007 20 13.5Every answer matches the plan. Notes on the choices:
- Question 1 is a mask's
sum(), notlen(orders[...]). Both give 3, but the mask says "count" directly and builds no table. - Question 2 uses
!=for "outside the north branch".~(orders["branch"] == "north")means the same;!=is simpler for one value. - Question 3 keeps the original labels
1and6, because the task did not ask to renumber. Those labels tell you which rows ofordersthey were. - Question 4 counts before it writes, and the new value is computed from the same rows on the right-hand side:
orders.loc[discount, "price"] * 0.9. Both sides use the same mask, so each row gets 90% of its own price: 8.0 becomes 7.2, 15.0 becomes 13.5. The pen order1001(12 items) and the notebook (5 items) keep their prices.
And the three breakages:
- Without parentheses, question 3 raises
TypeError: Cannot perform 'rand_' …, because&was applied before<. orders[discount]["price"] = …prints theChainedAssignmentErrorwarning, the script carries on, and the final table still shows the old prices8.0and15.0. The line did nothing.- With
"East", question 3 quietly loses the east-branch pen and prints one row instead of two. No error, no warning: just a wrong answer that looks right. That is why the plan writes down the expected rows before the code runs. The only thing that catches this bug is a person who already knew the answer should have two rows.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz