Pandas loc vs iloc
.loc selects rows and columns by label (index values and column names), while .iloc selects by integer position (0, 1, 2...). The rule of thumb: loc = "what it's called," iloc = "where it sits."
Quick answer: Use df.loc[row_label, column_name] when you know the index labels and column names, and df.iloc[row_position, column_position] when you know integer positions counted from 0. loc slices include the end label, iloc slices exclude the stop position, and use loc for boolean Series masks (iloc rejects a Series mask on a label index), so you can filter and assign in one call such as df.loc[df["price"] > 15, "qty"] = 0.
What Is the Difference Between loc and iloc?
loc looks up the index labels you pass; iloc counts positions from zero regardless of what the index contains. On a default RangeIndex they look identical, which is exactly how bugs sneak in later.
import pandas as pd
df = pd.DataFrame(
{"price": [10, 20, 30], "qty": [5, 3, 8]},
index=["apple", "banana", "cherry"],
)
df.loc["banana", "price"] # 20 (by label)
df.iloc[1, 0] # 20 (row 1, column 0)
Why Are loc Slices Inclusive?
loc slices include both endpoints, while iloc slices follow normal Python rules and exclude the stop value. This is the single most common source of off-by-one surprises.
df.loc["apple":"banana"] # 2 rows: apple AND banana included
df.iloc[0:1] # 1 row: apple only (stop excluded)
Labels can't be "one past the end" the way integers can, so pandas made label slicing inclusive on purpose.
How Do Boolean Masks Work with loc?
Pass a boolean Series to loc to filter rows, optionally combined with a column selection in one step. iloc does not accept a boolean Series aligned by label โ use loc for masks.
df.loc[df["price"] > 15] # rows where price > 15
df.loc[df["price"] > 15, "qty"] # just the qty column
df.loc[(df["price"] > 15) & (df["qty"] > 5), ["price", "qty"]]
How Does loc Avoid SettingWithCopyWarning?
Chained indexing like df[df.price > 15]["qty"] = 0 writes to a temporary copy, so pandas 2.x and earlier raise SettingWithCopyWarning and your data may not change. A single loc call with row and column selectors assigns directly to the original DataFrame.
# Bad โ two operations, writes to a copy:
df[df["price"] > 15]["qty"] = 0 # SettingWithCopyWarning (pandas < 3.0)
# Good โ one loc call, one operation:
df.loc[df["price"] > 15, "qty"] = 0 # works, no warning
In pandas 3.0, copy-on-write is the default, so the chained version never modifies df and pandas emits a ChainedAssignmentError warning instead of SettingWithCopyWarning.
Common Pitfalls
Most loc and iloc bugs come from integer index labels that look like positions, slice endpoints, and mixing either accessor with plain df[...] indexing. Check these four cases before you trust a selection.
- Integer labels are ambiguous: if your index is
[2, 0, 1],df.loc[1]returns the row labeled 1 (the third row), butdf.iloc[1]returns the second row (labeled 0). Never guess โ check the index. - Slice endpoints:
loc["a":"c"]includes "c";iloc[0:2]excludes row 2. - KeyError vs IndexError: a missing label raises
KeyErrorfromloc; an out-of-range position raisesIndexErrorfromiloc. - Plain
df[...]is inconsistent: it slices rows with a slice but selects columns with a string. Be explicit withloc/ilocin production code.
Once rows are selected, aggregating them is usually the next step โ see pandas groupby for that half of the workflow.
Pro Tip: For reading or writing a single cell, use df.at["banana", "price"] (label) or df.iat[1, 0] (position). They only handle scalars, which makes them noticeably faster than loc/iloc inside loops.