All 57 lessons
Lesson 30 of 57 · Data Analysis with Pandas
Pandas Selecting and Filtering: loc, iloc, Masks and query
By Skillancy Editorial Team
Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).
Analysis is mostly asking narrow questions of a big table: "only the delivered orders", "Mumbai stores in the evening slot", "the 50 slowest deliveries", "customers whose name starts with A". In SQL you would write a WHERE clause and a SELECT list. In pandas the same job is done by selecting (choosing rows and columns by label or position) and filtering (choosing rows by a condition). These two skills are the ones you will use hundreds of times a day, so we are going to learn them thoroughly.
The most important rule to take away is this: a condition produces a boolean Series, and passing that Series inside brackets keeps the rows where it is True. You met this idea in NumPy as the boolean mask. Everything in this lesson (comparisons, isin, between, string tests, combining conditions) is a way of producing a good mask. We will also cover the safe way to modify filtered data in pandas 3 and a few pitfalls that confuse almost every beginner.
Worked examples
Example 1: Columns, rows and slices
Question: Pick specific columns, and the first rows, using brackets.
import pandas as pd
import numpy as np
orders = pd.read_csv("orders.csv")
print(orders["city"] if "city" in orders else "no city column here")
print(orders[["order_id", "store_id", "order_status"]].head(3))
print(orders[:3].shape)
print(orders[10:13][["order_id"]])Output
no city column here
order_id store_id order_status
0 O02299 S03 Delivered
1 O04533 S07 Delivered
2 O00778 S09 Delivered
(3, 9)
order_id
10 O00619
11 O00918
12 O03406How it works: df["col"] selects a column, df[["a", "b"]] selects several, and df[10:13] with a slice selects rows by position. Using plain brackets for both columns and rows is convenient but ambiguous, which is why loc and iloc exist. The orders table has no city column (cities live in stores.csv), so the first line returns a message instead of failing.
Reading the result: The slice [:3] returns 3 rows and the column list returns three columns. Avoid mixing meanings in one bracket.
Example 2: loc and iloc
Question: Select by label with loc and by position with iloc.
small = orders.set_index("order_id").head(6)
print(small.iloc[0])
print(small.iloc[1:3, 0:2])
first_id, third_id = small.index[1], small.index[3]
print(small.loc[first_id, "delivery_minutes"])
print(small.loc[first_id:third_id, ["store_id", "order_status"]])
print(small.iloc[-1]["order_status"])Output
customer_id C0078
store_id S03
order_date 2025-01-01
delivery_slot Evening (4-8)
payment_method Card
order_status Delivered
delivery_minutes 34.0
discount_pct 15
Name: O02299, dtype: object
customer_id store_id
order_id
O04533 C0230 S07
O00778 C0328 S09
24.0
store_id order_status
order_id
O04533 S07 Delivered
O00778 S09 Delivered
O03607 S02 Delivered
DeliveredHow it works: iloc[row_selection, column_selection] uses integer positions, with end-exclusive slices like lists. loc[row_selection, column_selection] uses labels and slices are inclusive of the end label. After set_index("order_id") the row labels are order ids, so we can ask for an order by its id, such as small.loc[first_id, ...]. Remember: i = integer position, loc = label.
Reading the result: The order O04533 took 24 minutes. Label slices include both ends, which surprises people used to Python slices.
Example 3: Boolean filters
Question: Find delivered orders that were slow.
delivered = orders[orders["order_status"] == "Delivered"]
print(len(delivered))
slow = orders[orders["delivery_minutes"] > 40]
print(len(slow))
print((orders["delivery_minutes"] > 40).head(3).tolist())
print(orders["delivery_minutes"].gt(40).sum())
cancelled = orders[orders["order_status"] != "Delivered"]
print(len(cancelled), len(orders) - len(delivered))Output
5391
51
[False, False, False]
51
609 609How it works: orders["delivery_minutes"] > 40 produces a boolean Series with one True/False per row. Putting that inside orders[...] keeps only True rows. The method forms (gt, lt, ge, le, eq, ne) are equivalent to the operators and can be chained. Rows with NaN compare as False, so cancelled orders never appear in slow.
Reading the result: 5,391 orders were delivered and 51 took longer than 40 minutes. The remaining 609 were cancelled or returned.
Example 4: Combining conditions
Question: Evening deliveries in Delhi and Mumbai stores that took over 35 minutes, or any order paid by wallet with a deep discount.
stores = pd.read_csv("stores.csv")
metro_stores = stores.loc[stores["city"].isin(["Delhi", "Mumbai"]), "store_id"]
print(list(metro_stores))
evening_late = orders[
orders["store_id"].isin(metro_stores)
& orders["delivery_slot"].str.startswith("Evening")
& (orders["delivery_minutes"] > 35)
]
print(len(evening_late))
wallet_or_deep = orders[(orders["payment_method"] == "Wallet") | (orders["discount_pct"] >= 20)]
print(len(wallet_or_deep))
not_upi = orders[~(orders["payment_method"] == "UPI")]
print(len(not_upi))Output
['S03', 'S04', 'S05', 'S06']
29
938
2670How it works: Combine masks with & (and), | (or) and ~ (not). Each condition must be wrapped in brackets, because & binds more tightly than comparison operators. Splitting a long condition over several lines inside the outer brackets is the clearest style. Do not use the Python words and/or: they fail on Series.
Reading the result: 29 evening orders from the Delhi and Mumbai stores took over 35 minutes, and 938 orders either used a wallet or had a discount of 20 percent or more.
Example 5: isin, between, and missing values
Question: Use shortcuts for membership, ranges and blanks.
print(orders["payment_method"].isin(["UPI", "Card"]).sum())
print(orders["delivery_minutes"].between(20, 30).sum())
print(orders["delivery_minutes"].between(20, 30, inclusive="left").sum())
print(orders["delivery_minutes"].isna().sum(), orders["delivery_minutes"].notna().sum())
print(orders[orders["delivery_minutes"].isna()]["order_status"].value_counts())
print(orders["discount_pct"].value_counts().sort_index().to_dict())Output
4555
3209
2967
370 5630
order_status
Cancelled 370
Name: count, dtype: int64
{0: 2998, 5: 716, 10: 1056, 15: 724, 20: 506}How it works: isin replaces a chain of == and |. between(a, b) is inclusive on both ends by default (inclusive="left", "right", "neither" change that). isna() and notna() find blanks; never test with == np.nan. The last check confirms that every row with a blank delivery time is a cancelled order.
Reading the result: 3,209 orders took 20 to 30 minutes inclusive, and the blank delivery times all belong to Cancelled orders. Discount percentages take only the values shown, so they are effectively categories.
Example 6: String filters
Question: Search the customers table by name and email.
customers = pd.read_csv("customers.csv")
starts_a = customers[customers["name"].str.startswith("A")]
print(len(starts_a))
contains = customers[customers["name"].str.contains("sharma", case=False)]
print(len(contains), contains["name"].head(3).tolist())
gmail_like = customers["email"].str.endswith("@example.com").sum()
print(gmail_like)
pattern = customers[customers["name"].str.contains(r"^(Isha|Diya)\b", regex=True)]
print(len(pattern))
print(customers["city"].str.lower().str.contains("pune|delhi").sum())Output
135
34 ['Priya Sharma', 'Nikhil Sharma', 'Suresh Sharma']
600
43
169How it works: The .str accessor applies string methods to a whole column: startswith, endswith, contains (with optional case=False and regular expressions), lower, strip, split and more. Rows with missing text return NaN, which can break a filter; pass na=False to treat missing as False.
Reading the result: 135 customers have a name beginning with A, and 34 contain "sharma" in any case. A regular expression like ^(Isha|Diya)\b matched 43 names; the regex lesson explains the syntax.
Example 7: query
Question: Write a filter in a SQL-like readable form.
q1 = orders.query("order_status == 'Delivered' and delivery_minutes > 40")
print(len(q1))
limit = 35
q2 = orders.query("delivery_minutes > @limit and store_id in ['S01', 'S02']")
print(len(q2))
q3 = orders.query("payment_method == 'UPI' and discount_pct >= 15").shape
print(q3)
cols = ["order_id", "delivery_minutes"]
print(orders.query("delivery_minutes < 8")[cols].head(3))Output
50
62
(682, 9)
Empty DataFrame
Columns: [order_id, delivery_minutes]
Index: []How it works: query takes the condition as a string; column names act like variables, and/or/not work, and @name refers to a Python variable. It reads well and chains neatly, though it is slightly harder to debug than masks, and column names with spaces need backticks.
Reading the result: The delivered-and-slow query gave 50 rows, the same as the equivalent mask, and the query using @limit returned 62. Choose whichever style your team finds clearer.
Example 8: Conditional columns with loc, where and select
Question: Create a delivery performance label.
orders["speed"] = "unknown"
orders.loc[orders["delivery_minutes"] <= 20, "speed"] = "fast"
orders.loc[orders["delivery_minutes"].between(20.01, 35), "speed"] = "normal"
orders.loc[orders["delivery_minutes"] > 35, "speed"] = "slow"
print(orders["speed"].value_counts())
orders["is_late"] = np.where(orders["delivery_minutes"] > 30, "late", "on time")
conditions = [orders["delivery_minutes"].isna(), orders["delivery_minutes"] <= 20, orders["delivery_minutes"] <= 35]
choices = ["no delivery", "fast", "normal"]
orders["speed2"] = np.select(conditions, choices, default="slow")
print(orders["speed2"].value_counts())
print((orders["speed"].replace("unknown", "no delivery") == orders["speed2"]).all())Output
speed
normal 3514
fast 1836
unknown 370
slow 280
Name: count, dtype: int64
speed2
normal 3514
fast 1836
no delivery 370
slow 280
Name: count, dtype: int64
TrueHow it works: df.loc[mask, "col"] = value sets values only where the mask is true, creating the column if needed. np.where(cond, a, b) is the vectorised if/else. np.select(conditions, choices, default=...) handles several cases in order; the first matching condition wins. This is how you translate a SQL CASE WHEN expression.
Reading the result: The label counts show 1,836 fast, 3,514 normal and 280 slow deliveries, and both methods agree (True).
Example 9: Index tricks
Question: Use the index for lookups and reset it again.
products = pd.read_csv("products.csv").set_index("product_id")
print(products.loc["P001", "product_name"])
print(products.loc[["P001", "P002"], ["product_name", "unit_price"]])
print("P999" in products.index, products.index.is_unique)
by_cat = products.reset_index().set_index(["category", "product_id"]).sort_index()
print(by_cat.loc["Dairy"].head(3)[["product_name", "unit_price"]])
flat = products.reset_index(drop=False)
print(flat.columns[0], flat.shape)Output
Banana Robusta
product_name unit_price
product_id
P001 Banana Robusta 48
P002 Tomato Hybrid 36
False True
product_name unit_price
product_id
P009 Full Cream Milk 1L 68
P010 Curd 400g 45
P011 Paneer 200g 95
product_id (50, 7)How it works: set_index turns a column into the row labels, which makes loc lookups by id convenient and also aligns data when you combine tables. A multi-index uses several columns as labels, and loc["Dairy"] then selects the whole group. reset_index() moves the labels back into an ordinary column. Index values should usually be unique.
Reading the result: Product P001 is Banana Robusta. The id is unique (True), so label lookups return a single row.
Example 10: Chained assignment and copies
Question: Why do you sometimes see a "SettingWithCopy" warning, and what does pandas 3 do?
subset = orders[orders["store_id"] == "S01"]
subset["flag"] = 1
print("flag" in subset.columns, "flag" in orders.columns)
# modify the original safely, in one step
orders.loc[orders["store_id"] == "S01", "is_s01"] = True
print(orders["is_s01"].sum(), orders["is_s01"].isna().sum())
# chained assignment no longer modifies the original in pandas 3
orders[orders["store_id"] == "S02"]["marker"] = "x"
print("marker" in orders.columns)
safe_copy = orders[orders["store_id"] == "S03"].copy()
safe_copy["flag"] = 1
print(safe_copy["flag"].sum() == len(safe_copy))Output
True False
682 5318
False
TrueHow it works: In pandas 3 (with copy-on-write), a filtered result behaves as an independent object: changing subset never changes orders, and orders[mask]["col"] = value is chained assignment, which has no effect on the original. To modify the original rows, use a single loc call: df.loc[mask, "col"] = value. When you want to work on a subset separately, call .copy() to make your intention explicit.
Reading the result: The flag went onto the subset only (False for the original). The loc assignment marked 682 rows of store S01 in the original table, and the chained assignment created no marker column at all (False).
Common mistakes
1. Using and/or instead of &/|.
orders[(orders["delivery_minutes"] > 40) and (orders["store_id"] == "S01")]Output
Traceback (most recent call last):
File "example.py", line 1, in <module>
orders[(orders["delivery_minutes"] > 40) and (orders["store_id"] == "S01")]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "example.py", line 1501, in __bool__
raise ValueError(
...<2 lines>...
)
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().2. Forgetting brackets around each condition.
3. Chained assignment: df[mask]["col"] = x (use df.loc[mask, "col"] = x).
4. Testing equality with NaN: use isna().
5. Confusing loc (inclusive label slices) with iloc (exclusive position slices).
Practice questions
Practice 1 (Easy): Prime customers
From customers, select the Prime customers living in Mumbai and show how many there are.
Show solution
prime_mumbai = customers[customers["is_prime"] & (customers["city"] == "Mumbai")]
print(len(prime_mumbai))Output
33Because is_prime is already boolean, it can be used directly as a mask. There are 33 such customers.
Practice 2 (Medium): Big discounts on fast deliveries
How many orders were delivered in under 15 minutes and had a discount of at least 10 percent? What share of all delivered orders is that?
Show solution
mask = (orders["delivery_minutes"] < 15) & (orders["discount_pct"] >= 10)
n = mask.sum()
share = n / (orders["order_status"] == "Delivered").sum()
print(n, f"{share:.1%}")Output
211 3.9%The sum of a boolean mask is the count. 211 orders qualify, about 3.9% of delivered orders.
Practice 3 (Hard): Slowest 5% per query
Using quantile, keep only the deliveries slower than the 95th percentile, then report how many stores they come from and which store appears most often.
Show solution
cutoff = orders["delivery_minutes"].quantile(0.95)
tail = orders[orders["delivery_minutes"] > cutoff]
print(cutoff, len(tail))
print(tail["store_id"].nunique())
print(tail["store_id"].value_counts().head(3))Output
35.0 280
11
store_id
S07 45
S06 33
S01 32
Name: count, dtype: int64The 95th percentile is 35 minutes, giving 280 very slow deliveries spread over 11 stores. S07 appears most often.
Key takeaways
- A condition produces a boolean Series;
df[mask]keeps theTruerows. - Combine masks with
&,|,~and wrap each condition in brackets. locis label-based (inclusive),ilocis position-based (exclusive).isin,between,isnaand the.strmethods build masks quickly;queryis the readable alternative.- Create conditional columns with
loc,np.whereandnp.select. - Modify the original in one step with
df.loc[mask, "col"] = value; avoid chained assignment.
Frequently asked questions
What is the difference between loc and iloc?
loc selects by label (and slices include the end label), while iloc selects by integer position (end exclusive). When the index is the default 0, 1, 2..., they look alike until you filter or sort.
Why do I need brackets around each condition?
In Python, & has higher precedence than > or ==. Without brackets a > 1 & b < 2 is parsed as a > (1 & b) < 2, which causes errors.
Is query faster than boolean masks?
For large tables it can be a little faster and uses less memory in some cases, but the main benefit is readability. Use whichever is clearer.
How do I filter on a column name with spaces?
Use df["column name"] in masks, or backticks in query: `df.query("unit price > 50")`. Better still, rename the columns to snake_case.
How do I get the index of rows that match a condition?
df.index[mask] or df[mask].index. For positions rather than labels use np.flatnonzero(mask).
Do I need .copy() in pandas 3?
Mostly no: copy-on-write makes results independent automatically. It is still a clear way to show your intent when you create a working subset, and it is required on older pandas versions.
Keep learning Python
One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Ready to practise? Try the Python interview questions