The idea
Plain square brackets in pandas do different things depending on what you pass: a name selects a column, a list selects columns, a slice selects rows, a mask filters rows. That's handy, but ambiguous. pandas has two explicit selectors that always mean the same thing:
.loc[rows, columns]selects by label: index labels and column names..iloc[rows, columns]selects by integer position: 0, 1, 2… like a list.
Both take two parts separated by a comma: which rows, then which columns. Professional pandas code uses them for anything beyond a quick column lookup.
To see the difference clearly, use product names as the index:
import pandas as pd
products = pd.DataFrame({
"name": ["French Press 600ml", "Arabica Gayo 250g", "Ceramic Mug", "Gel Pen Set", "USB-C Hub"],
"category": ["Kitchen", "Coffee", "Kitchen", "Stationery", "Electronics"],
"price": [185000, 95000, 55000, 38000, 289000],
"stock": [24, 80, 150, 0, 15],
}).set_index("name")
print(products)
category price stock
name
French Press 600ml Kitchen 185000 24
Arabica Gayo 250g Coffee 95000 80
Ceramic Mug Kitchen 55000 150
Gel Pen Set Stationery 38000 0
USB-C Hub Electronics 289000 15
.set_index("name") moves the name column into the index, so each row is labelled by its product.
.loc: by label
This module uses a small products table where each row is labelled with the product's name. .loc finds things by those labels: you say which row (or rows) and which column (or columns) you want, separated by a comma. Here's what you get back depending on how much you ask for:
print(products.loc["Ceramic Mug", "price"])
print(products.loc["Ceramic Mug"])
print(products.loc[["Gel Pen Set", "USB-C Hub"], ["price", "stock"]])
55000
category Kitchen
price 55000
stock 150
Name: Ceramic Mug, dtype: object
price stock
name
Gel Pen Set 38000 0
USB-C Hub 289000 15
- One row label and one column name → a single value.
- Just a row label → that row, as a Series.
- Lists of labels → a DataFrame.
Use : to mean "all":
print(products.loc[:, "category"].tolist())
print(products.loc["Arabica Gayo 250g":"Gel Pen Set", "price"])
['Kitchen', 'Coffee', 'Kitchen', 'Stationery', 'Electronics']
name
Arabica Gayo 250g 95000
Ceramic Mug 55000
Gel Pen Set 38000
Name: price, dtype: int64
Label slices with .loc include the end. "Arabica Gayo 250g":"Gel Pen Set" returns three products, Gel Pen Set included. Position slices (with .iloc or lists) exclude the end. This is the most common loc/iloc mix-up.
.loc with a boolean mask
.loc accepts a mask for the rows, which lets you filter and pick columns in one step:
low = products.loc[products["stock"] < 20, ["category", "stock"]]
print(low)
category stock
name
Gel Pen Set Stationery 0
USB-C Hub Electronics 15
This is the idiomatic way to write "these columns, for rows where…".
.iloc: by position
Sometimes you don't care what a row is called, only where it is: the first row, the last row, the top ten. That's what .iloc is for. It ignores the labels completely and counts positions from 0, exactly like a Python list:
print(products.iloc[0])
print(products.iloc[-1, 1])
print(products.iloc[1:3, [0, 2]])
category Kitchen
price 185000
stock 24
Name: French Press 600ml, dtype: object
289000
category stock
name
Arabica Gayo 250g Coffee 80
Ceramic Mug Kitchen 150
iloc[0]is the first row, whatever its label.iloc[-1, 1]is the last row, second column (price).iloc[1:3, [0, 2]]is rows at positions 1 and 2 (end excluded), columns at positions 0 and 2.
Use .iloc when position is what you mean: "the first 10 rows", "the last row", "every other row". Use .loc when you mean a specific thing by name. Code that says iloc[3] when it means "the Gel Pen Set" breaks the moment the data is sorted differently.
When the index is 0, 1, 2…
With the default numbered index, .loc and .iloc look the same, which is a trap:
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
print(completed.index[:5].tolist())
print(completed.loc[22, "product"])
print(completed.iloc[0]["product"])
[0, 1, 2, 3, 4]
Pour-over Kettle
Desk Organizer
After filtering, the index labels keep their original values (they're labels, not positions). completed.loc[22] means "the row labelled 22", while completed.iloc[0] means "the first remaining row". If you want fresh 0, 1, 2… labels after filtering, call .reset_index(drop=True).
Changing values safely
.loc is also how you set values, including for rows matching a condition:
products.loc["Gel Pen Set", "stock"] = 40
products.loc[products["category"] == "Kitchen", "price"] = (
products.loc[products["category"] == "Kitchen", "price"] * 0.9
).round().astype(int)
print(products[["category", "price", "stock"]])
category price stock
name
French Press 600ml Kitchen 166500 24
Arabica Gayo 250g Coffee 95000 80
Ceramic Mug Kitchen 49500 150
Gel Pen Set Stationery 38000 40
USB-C Hub Electronics 289000 15
Every Kitchen price is now 10% lower, and the pen set is restocked, in place.
The SettingWithCopyWarning
You'll eventually see this warning. It comes from chained selection followed by assignment:
products[products["stock"] == 0]["stock"] = 50 # SettingWithCopyWarning, may do nothing
products.loc[products["stock"] == 0, "stock"] = 50 # correct: one .loc, rows and column together
The first line selects some rows (making a new, temporary DataFrame) and then sets a value in that temporary copy, so your real data may not change at all. pandas warns you because it can't tell whether you meant to change the original. The fix is always the same: do the selection and assignment in one .loc call. If you genuinely want a separate DataFrame to modify, make it explicit with .copy(): low_stock = products[products["stock"] < 20].copy().
Why this matters for clean code
.loc and .iloc make your intent unambiguous: "this product's price" versus "the third row". Use .loc for meaning and filtering, .iloc for position, a single .loc[mask, column] = value for updates, and .copy() when you want an independent subset. Code written this way doesn't silently stop working when the data is sorted, filtered or re-indexed.
Recap
.loc[rows, cols]selects by label;.iloc[rows, cols]by position.:means all..loclabel slices include the end;.ilocslices exclude it..loc[mask, ["a", "b"]]filters rows and picks columns in one step.- After filtering, index labels keep their old values;
.reset_index(drop=True)renumbers. - Set values with
df.loc[row_selector, "col"] = valuein one step to avoid the SettingWithCopyWarning. - Use
.copy()when you want an independent subset to modify.