.loc selects by label, and .iloc selects by integer position. They look similar and behave differently, mainly because the label index of a DataFrame is not always 0, 1, 2 and so on.
The same table, two ways
df = pd.DataFrame(
{"city": ["Pune", "Delhi", "Goa"], "sales": [10, 20, 30]},
index=["a", "b", "c"],
)
df.loc["b", "sales"] # 20 (row labelled "b")
df.iloc[1, 1] # 20 (second row, second column)Slices behave differently
df.loc["a":"b"] # rows a AND b (label slices include the end) df.iloc[0:1] # row 0 only (position slices exclude the end, like Python lists)
This is a favourite trap. With .loc, the end of a slice is included. With .iloc, it is not.
Surprise when the index is numeric
After filtering or sorting, the index labels are no longer in order. If df has index [5, 6, 7], then df.loc[5] is the first row and df.iloc[5] raises an error (there is no sixth row). If you are not sure, df.reset_index(drop=True) first.
Boolean masks
.loc accepts a condition, which makes it the standard for filtering and for assigning to a subset:
df.loc[df["sales"] > 15, "city"] # select df.loc[df["sales"] > 15, "flag"] = "high" # assign
Why use .loc for assignment
Chained indexing such as df[df["sales"] > 15]["flag"] = "high" may assign to a temporary copy, so the original DataFrame does not change, and pandas raises SettingWithCopyWarning. With df.loc[mask, "flag"] = "high", the row and column are selected and set in a single step on the original. In pandas 3 with copy-on-write behaviour, chained assignment stops working at all, so write it with .loc from the start.
Quick rule
Names: .loc. Positions: .iloc. Always use .loc to set values.