PYTHON / PANDAS
Selecting rows and columns with loc and iloc
Select exact rows and columns by label with .loc and by integer position with .iloc, and know which one a given task needs.
What you will learn
- Use .loc[row_label, column_label] and .iloc[row_pos, column_pos] deliberately
- Remember .loc slices include the end label; .iloc slices exclude the end position
- Combine a boolean mask with a column label in a single .loc call
- Assign into a subset with df.loc[mask, 'col'] instead of chained indexing
Understanding Selecting rows and columns with loc and iloc
A DataFrame stores two separate things for every axis: the labels in the index (or column names) and the underlying 0-based order of the values. .loc speaks only labels, .iloc speaks only positions. With an index like ['mon', 'tue', 'wed', 'thu'] the difference is obvious, because sales.loc['tue'] and sales.iloc[1] happen to point at the same row only because the labels were created in that order. Sort or filter the frame and the labels travel with their rows while the positions are renumbered, so the two accessors start disagreeing.
Both accessors take two arguments separated by a comma: rows first, then columns. sales.loc['tue', 'units'] returns a single scalar, sales.loc['tue'] returns that whole row as a Series, and sales.loc[:, 'units'] returns the whole column. Passing a list, like sales.loc[['mon', 'wed'], ['units', 'price']], keeps the result two-dimensional, while passing a single label collapses that axis. That collapsing rule is why df.loc[['a']] gives a one-row DataFrame but df.loc['a'] gives a Series.
Slicing is where the two accessors visibly diverge. sales.loc['tue':'thu'] includes 'thu', because a label tells pandas nothing about what follows it, so the only sensible reading of "up to thu" is "up to and including thu". sales.iloc[1:3] follows ordinary Python half-open slicing and stops before position 3. The trap appears when the index is made of integers: with index=[10, 20, 30], s.loc[10:20] returns two elements by label while s.iloc[10:20] returns nothing at all, because there is no position 10.
import pandas as pd
sales = pd.DataFrame(
{"units": [12, 30, 7, 21], "price": [2.5, 1.0, 4.75, 3.0]},
index=["mon", "tue", "wed", "thu"],
)
print(sales.loc["tue", "units"])
print(sales.iloc[1, 0])
print(sales.loc["tue":"thu", "units"])
print(sales.iloc[1:3, 0]).loc addresses data by the labels printed in the index, .iloc by the hidden 0-based position, and those two coordinate systems drift apart as soon as rows are reordered or removed.
Worked examples
Integer labels are not positions
Shows how an integer index makes label-based and position-based access completely different operations.
import pandas as pd
s = pd.Series(["a", "b", "c"], index=[10, 20, 30])
print(s.loc[10])
print(s.iloc[0])
print(s.loc[10:20])
print(s.iloc[0:2])Example explained
Line 1s.loc[10] looks up the label 10, which is the first element, not the eleventh.
Line 2s.iloc[0] asks for position 0 and happens to return the same value here.
Line 3s.loc[10:20] includes the label 20, so two elements come back.
Line 4s.iloc[0:2] stops before position 2, so it also returns two elements, but for a completely different reason.
Mask for rows, label for columns
Uses .loc to write into a subset and to read one column from the rows that satisfy a condition.
import pandas as pd
df = pd.DataFrame(
{"name": ["Ada", "Linus", "Grace"], "score": [88, 74, 95], "team": ["x", "y", "x"]}
)
df.loc[df["score"] < 80, "score"] = 80
print(df.loc[[0, 2], ["name", "score"]])
print(df.loc[df["team"] == "x", "score"].tolist())Example explained
Line 1df.loc[mask, "score"] = 80 targets rows and one column in a single call, so the write lands in df itself.
Line 2The row argument is a boolean Series aligned by index; the column argument is a plain label.
Line 3df.loc[[0, 2], ["name", "score"]] passes lists on both axes, so the result stays a DataFrame with index labels 0 and 2.
Line 4Selecting a single column label after a mask yields a Series, which is why .tolist() works.
Translating a label into a position
Demonstrates that .iloc rejects labels outright and how index.get_loc bridges the two systems.
import pandas as pd
df = pd.DataFrame({"v": [5, 6, 7]}, index=["a", "b", "c"])
try:
df.iloc["a"]
except Exception as e:
print(type(e).__name__)
print(df.index.get_loc("b"))
print(df.iloc[df.index.get_loc("b"), 0])
print(df.iloc[-1, 0])Example explained
Line 1df.iloc["a"] raises instead of guessing: .iloc validates that every key is an integer, slice, list of integers, or boolean array.
Line 2df.index.get_loc("b") returns 1, the position currently held by label "b".
Line 3Feeding that number back into .iloc reaches the same row the label would have.
Line 4df.iloc[-1, 0] uses a negative position, which .loc cannot do unless -1 is an actual index label.
Important notes
A .loc slice needs the boundary labels to be findable: on an index with duplicate or unordered labels, a label slice can raise a KeyError even though the labels exist.
df.at and df.iat are the single-cell versions of .loc and .iloc; they are faster but accept only one label or one position per axis, never lists or slices.
Common mistakes
Treating df.loc[0] as "the first row" on a default index: after sorting or dropping rows, label 0 may sit anywhere, so you silently read the wrong record while df.iloc[0] reads the right one.
Expecting df.loc['b':'d'] to stop before 'd' like a Python slice, which quietly returns one row more than intended and skews any sum or mean computed from it.
Assigning through two brackets, as in df[df['score'] < 80]['score'] = 80, which writes into a temporary object; the original frame is unchanged, or the write fails, instead of updating the rows.
Try it yourself
Change, predict, then run
Build a DataFrame of four cities with columns population and area and a string index of city names, then print the population of the third city twice, once with .loc and once with .iloc. Sort the frame by population, print both expressions again, and note which one now points at a different city.
Open the Python workspaceCheck your understanding
With index ['a', 'b', 'c', 'd', 'e'], why does df.loc['b':'d'] return three rows while df.iloc[1:4] also returns three rows but would return only two if written as df.iloc[1:3]?
- A label carries no information about what follows it, so a label slice includes its end label, while a positional slice follows Python's half-open convention and stops before its end
- .loc always returns one more row than .iloc for the same numbers, as a convenience for label lookups
- Label slices are inclusive only while the index stays sorted; on an unsorted index .loc becomes exclusive like .iloc
- Both accessors are exclusive, and the extra row appears because .loc silently resets the index before slicing
Show answer
The inclusive end of a label slice follows from the labels themselves: pandas cannot compute "the label before 'd'" without scanning, so 'b':'d' is defined as everything from 'b' through 'd'. Positions are arithmetic, so 1:4 means the usual 4 - 1 = 3 items. Option 3 is tempting because sortedness does matter for label slicing, but it changes whether the slice succeeds at all (a non-monotonic index can raise KeyError), not whether the endpoint is included.